diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 0202fc27c43929811ccec2f6dfc09bb9f603045c..9642f0b06a4734cfc3947ac654cbb23c37ff0dfc 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -189,15 +189,15 @@ function withFakeCodexEnv( const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd()); it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { - it.effect("generates and sanitizes commit messages", () => + it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { output: JSON.stringify({ subject: " Add important change to the system with too much detail and a trailing period.\nsecondary line", body: "\n- added migration\n- updated tests\n", - branch: "fix/important-system-change", }), + stdinMustNotContain: "branch must be a short semantic git branch fragment", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -212,6 +212,33 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { expect(generated.subject.length).toBeLessThanOrEqual(72); expect(generated.subject.endsWith(".")).toBe(false); expect(generated.body).toBe("- added migration\n- updated tests"); + expect(generated.branch).toBeUndefined(); + }), + ), + ); + + it.effect("generates commit message with branch when includeBranch is true", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + branch: "fix/important-system-change", + }), + stdinMustContain: "branch must be a short semantic git branch fragment", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + includeBranch: true, + }); + + expect(generated.subject).toBe("Add important change"); expect(generated.branch).toBe("feature/fix/important-system-change"); }), ), diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 2707614e54905fb2fd6ad20913b3d7c109089ca7..313ce8d995b9a53dab7dfad6cfc4bcc7cad7a702 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -336,13 +336,19 @@ const makeCodexTextGeneration = Effect.gen(function* () { }); const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { + const wantsBranch = input.includeBranch === true; + const prompt = [ "You write concise git commit messages.", - "Return a JSON object with keys: subject, body, branch.", + wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", "Rules:", "- subject must be imperative, <= 72 chars, and no trailing period", "- body can be empty string or short bullet points", - "- branch must be a short semantic git branch fragment for this change", + ...(wantsBranch + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), "- capture the primary user-visible or developer-visible change", "", `Branch: ${input.branch ?? "(detached)"}`, @@ -354,22 +360,31 @@ const makeCodexTextGeneration = Effect.gen(function* () { limitSection(input.stagedPatch, 40_000), ].join("\n"); + const outputSchemaJson = wantsBranch + ? Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }) + : Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }); + return runCodexJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson: Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.String, - }), + outputSchemaJson, }).pipe( Effect.map( (generated) => ({ subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), - branch: sanitizeFeatureBranchName(generated.branch), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), }) satisfies CommitMessageGenerationResult, ), ); @@ -419,7 +434,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { return Effect.gen(function* () { - const { imagePaths } = yield* materializeImageAttachments("generateBranchName", input.attachments); + const { imagePaths } = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index cc05c6563c38b020a71b8c8818833c0541594d0e..d0720c12f36278e3a52d3fc62c166284af5812a8 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -32,7 +32,11 @@ interface FakeGitTextGeneration { branch: string | null; stagedSummary: string; stagedPatch: string; - }) => Effect.Effect<{ subject: string; body: string; branch: string }, TextGenerationError>; + includeBranch?: boolean; + }) => Effect.Effect< + { subject: string; body: string; branch?: string | undefined }, + TextGenerationError + >; generatePrContent: (input: { cwd: string; baseBranch: string; @@ -104,11 +108,13 @@ function createBareRemote(): Effect.Effect< function createTextGeneration(overrides: Partial = {}): TextGenerationShape { const implementation: FakeGitTextGeneration = { - generateCommitMessage: () => + generateCommitMessage: (input) => Effect.succeed({ subject: "Implement stacked git actions", body: "", - branch: "feature/implement-stacked-git-actions", + ...(input.includeBranch + ? { branch: "feature/implement-stacked-git-actions" } + : {}), }), generatePrContent: () => Effect.succeed({ @@ -506,13 +512,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ textGeneration: { - generateCommitMessage: () => + generateCommitMessage: (input) => Effect.sync(() => { generatedCount += 1; return { subject: "this should not be used", body: "", - branch: "feature/unused", + ...(input.includeBranch ? { branch: "feature/unused" } : {}), }; }), }, @@ -566,13 +572,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ textGeneration: { - generateCommitMessage: () => + generateCommitMessage: (input) => Effect.sync(() => { generatedCount += 1; return { subject: "unused", body: "", - branch: "feature/unused", + ...(input.includeBranch ? { branch: "feature/unused" } : {}), }; }), }, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 757aafc117836ef1228c74c263700f917b264680..92d6fffcecdf57facbf1b42e8ae6899468a8d901 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -85,10 +85,14 @@ function limitContext(value: string, maxChars: number): string { return `${value.slice(0, maxChars)}\n\n[truncated]`; } -function sanitizeCommitMessage(generated: { subject: string; body: string; branch: string }): { +function sanitizeCommitMessage(generated: { subject: string; body: string; - branch: string; + branch?: string | undefined; +}): { + subject: string; + body: string; + branch?: string | undefined; } { const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; const subject = rawSubject.replace(/[.]+$/g, "").trim(); @@ -96,7 +100,7 @@ function sanitizeCommitMessage(generated: { subject: string; body: string; branc return { subject: safeSubject, body: generated.body.trim(), - branch: generated.branch, + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), }; } @@ -293,6 +297,8 @@ export const makeGitManager = Effect.gen(function* () { cwd: string; branch: string | null; commitMessage?: string; + /** When true, also produce a semantic feature branch name. */ + includeBranch?: boolean; }) => Effect.gen(function* () { const context = yield* gitCore.prepareCommitContext(input.cwd); @@ -302,11 +308,12 @@ export const makeGitManager = Effect.gen(function* () { const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); if (customCommit) { - const branch = sanitizeFeatureBranchName(customCommit.subject); return { subject: customCommit.subject, body: customCommit.body, - branch, + ...(input.includeBranch + ? { branch: sanitizeFeatureBranchName(customCommit.subject) } + : {}), commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), }; } @@ -317,14 +324,16 @@ export const makeGitManager = Effect.gen(function* () { branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.includeBranch !== undefined ? { includeBranch: input.includeBranch } : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); - const branch = sanitizeFeatureBranchName(generated.branch); return { subject: generated.subject, body: generated.body, - branch, + ...(generated.branch !== undefined + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), commitMessage: formatCommitMessage(generated.subject, generated.body), }; }); @@ -456,6 +465,7 @@ export const makeGitManager = Effect.gen(function* () { cwd: input.cwd, branch: details.branch, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + includeBranch: true, }); if (!suggestion) { return yield* gitManagerError( @@ -466,7 +476,7 @@ export const makeGitManager = Effect.gen(function* () { return { commitMessage: suggestion.commitMessage, - branch: suggestion.branch, + branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject), }; }, ); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index b13114ddfb59619c4aa23f517375a1e8e110b997..daae27fe66d4e7b313c72a741a39ea2dbc14d3de 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -17,12 +17,15 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; + /** When true, the model also returns a semantic branch name for the change. */ + includeBranch?: boolean; } export interface CommitMessageGenerationResult { subject: string; body: string; - branch: string; + /** Only present when `includeBranch` was set on the input. */ + branch?: string | undefined; } export interface PrContentGenerationInput {