Add structured agent helper and checkout git fixes
This commit is contained in:
Generated
+42
-1
@@ -9872,6 +9872,7 @@
|
|||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -25187,6 +25188,7 @@
|
|||||||
"@openrouter/ai-sdk-provider": "^1.2.0",
|
"@openrouter/ai-sdk-provider": "^1.2.0",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "^5.0.76",
|
"ai": "^5.0.76",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
@@ -25200,7 +25202,8 @@
|
|||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8",
|
||||||
|
"zod-to-json-schema": "^3.25.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
@@ -25238,6 +25241,22 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
@@ -25293,6 +25312,28 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"packages/server/node_modules/body-parser": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@openrouter/ai-sdk-provider": "^1.2.0",
|
"@openrouter/ai-sdk-provider": "^1.2.0",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "^5.0.76",
|
"ai": "^5.0.76",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8",
|
||||||
|
"zod-to-json-schema": "^3.25.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
export type AgentCaller = (prompt: string) => Promise<string>;
|
||||||
|
|
||||||
|
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<T> {
|
||||||
|
caller: AgentCaller;
|
||||||
|
prompt: string;
|
||||||
|
schema: z.ZodType<T> | JsonSchema;
|
||||||
|
maxRetries?: number;
|
||||||
|
schemaName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StructuredAgentGenerationOptions<T> {
|
||||||
|
manager: AgentManager;
|
||||||
|
agentConfig: AgentSessionConfig;
|
||||||
|
agentId?: string;
|
||||||
|
prompt: string;
|
||||||
|
schema: z.ZodType<T> | JsonSchema;
|
||||||
|
maxRetries?: number;
|
||||||
|
schemaName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemaValidator<T> {
|
||||||
|
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<T>(schema: z.ZodTypeAny, schemaName: string): SchemaValidator<T> {
|
||||||
|
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<T>(schema: JsonSchema): SchemaValidator<T> {
|
||||||
|
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<T>(schema: z.ZodType<T> | JsonSchema, schemaName: string): SchemaValidator<T> {
|
||||||
|
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<T>(
|
||||||
|
options: StructuredAgentResponseOptions<T>
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>(
|
||||||
|
options: StructuredAgentGenerationOptions<T>
|
||||||
|
): Promise<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,12 +67,12 @@ import {
|
|||||||
generateAgentTitle,
|
generateAgentTitle,
|
||||||
isTitleGeneratorInitialized,
|
isTitleGeneratorInitialized,
|
||||||
} from "../services/agent-title-generator.js";
|
} from "../services/agent-title-generator.js";
|
||||||
|
import { createWorktree, slugify, validateBranchSlug } from "../utils/worktree.js";
|
||||||
import {
|
import {
|
||||||
createWorktree,
|
getCheckoutDiff,
|
||||||
detectRepoInfo,
|
getCheckoutStatus,
|
||||||
slugify,
|
NotGitRepoError,
|
||||||
validateBranchSlug,
|
} from "../utils/checkout-git.js";
|
||||||
} from "../utils/worktree.js";
|
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
|
|
||||||
@@ -1617,16 +1617,16 @@ export class Session {
|
|||||||
const resolvedCwd = expandTilde(cwd);
|
const resolvedCwd = expandTilde(cwd);
|
||||||
|
|
||||||
try {
|
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(
|
const { stdout: branchesRaw } = await execAsync(
|
||||||
"git branch --format='%(refname:short)'",
|
"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(
|
const currentBranch = status.currentBranch ?? "";
|
||||||
"git rev-parse --abbrev-ref HEAD",
|
|
||||||
{ cwd: resolvedCwd, env: READ_ONLY_GIT_ENV }
|
|
||||||
);
|
|
||||||
const currentBranch = currentRaw.trim();
|
|
||||||
const branches = branchesRaw
|
const branches = branchesRaw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
@@ -1636,13 +1636,13 @@ export class Session {
|
|||||||
isCurrent: name === currentBranch,
|
isCurrent: name === currentBranch,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const isDirty = await this.isWorkingTreeDirty(resolvedCwd);
|
const isDirty = status.isDirty ?? false;
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "git_repo_info_response",
|
type: "git_repo_info_response",
|
||||||
payload: {
|
payload: {
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
repoRoot: repoInfo.path,
|
repoRoot,
|
||||||
requestId,
|
requestId,
|
||||||
branches,
|
branches,
|
||||||
currentBranch: currentBranch || null,
|
currentBranch: currentBranch || null,
|
||||||
@@ -2140,40 +2140,8 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get diff for tracked files
|
const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" });
|
||||||
const { stdout: trackedDiff } = await execAsync("git diff HEAD", {
|
const combinedDiff = diffResult.diff;
|
||||||
cwd: agent.cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get diff for untracked files (new files not yet added to git)
|
|
||||||
// Using git diff --no-index /dev/null <file> 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;
|
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "git_diff_response",
|
type: "git_diff_response",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string | null> {
|
||||||
|
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<AheadBehind | null> {
|
||||||
|
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<string> {
|
||||||
|
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<CheckoutStatus> {
|
||||||
|
let repoInfo: Awaited<ReturnType<typeof detectRepoInfo>>;
|
||||||
|
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<CheckoutDiffResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<PullRequestStatus | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user