authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 09:34:33-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 20:46:05-08:00
log13094952186d16aaffea7609e78629bc5f9a57f2
treebe5e48fad379666fde91935458672f58ffa399d4
parentc34a4c69e5d58f48f68c53ebed22183905a9db15

optional


5 files changed, 91 insertions(+), 27 deletions(-)

apps/server/src/git/Layers/CodexTextGeneration.test.ts+29-2
......@@ -189,15 +189,15 @@ function withFakeCodexEnv<A, E, R>(
189189const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd());
190190
191191it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
192 it.effect("generates and sanitizes commit messages", () =>
192 it.effect("generates and sanitizes commit messages without branch by default", () =>
193193 withFakeCodexEnv(
194194 {
195195 output: JSON.stringify({
196196 subject:
197197 " Add important change to the system with too much detail and a trailing period.\nsecondary line",
198198 body: "\n- added migration\n- updated tests\n",
199 branch: "fix/important-system-change",
200199 }),
200 stdinMustNotContain: "branch must be a short semantic git branch fragment",
201201 },
202202 Effect.gen(function* () {
203203 const textGeneration = yield* TextGeneration;
......@@ -212,6 +212,33 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
212212 expect(generated.subject.length).toBeLessThanOrEqual(72);
213213 expect(generated.subject.endsWith(".")).toBe(false);
214214 expect(generated.body).toBe("- added migration\n- updated tests");
215 expect(generated.branch).toBeUndefined();
216 }),
217 ),
218 );
219
220 it.effect("generates commit message with branch when includeBranch is true", () =>
221 withFakeCodexEnv(
222 {
223 output: JSON.stringify({
224 subject: "Add important change",
225 body: "",
226 branch: "fix/important-system-change",
227 }),
228 stdinMustContain: "branch must be a short semantic git branch fragment",
229 },
230 Effect.gen(function* () {
231 const textGeneration = yield* TextGeneration;
232
233 const generated = yield* textGeneration.generateCommitMessage({
234 cwd: process.cwd(),
235 branch: "feature/codex-effect",
236 stagedSummary: "M README.md",
237 stagedPatch: "diff --git a/README.md b/README.md",
238 includeBranch: true,
239 });
240
241 expect(generated.subject).toBe("Add important change");
215242 expect(generated.branch).toBe("feature/fix/important-system-change");
216243 }),
217244 ),
apps/server/src/git/Layers/CodexTextGeneration.ts+27-9
......@@ -336,13 +336,19 @@ const makeCodexTextGeneration = Effect.gen(function* () {
336336 });
337337
338338 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
339 const wantsBranch = input.includeBranch === true;
340
339341 const prompt = [
340342 "You write concise git commit messages.",
341 "Return a JSON object with keys: subject, body, branch.",
343 wantsBranch
344 ? "Return a JSON object with keys: subject, body, branch."
345 : "Return a JSON object with keys: subject, body.",
342346 "Rules:",
343347 "- subject must be imperative, <= 72 chars, and no trailing period",
344348 "- body can be empty string or short bullet points",
345 "- branch must be a short semantic git branch fragment for this change",
349 ...(wantsBranch
350 ? ["- branch must be a short semantic git branch fragment for this change"]
351 : []),
346352 "- capture the primary user-visible or developer-visible change",
347353 "",
348354 `Branch: ${input.branch ?? "(detached)"}`,
......@@ -354,22 +360,31 @@ const makeCodexTextGeneration = Effect.gen(function* () {
354360 limitSection(input.stagedPatch, 40_000),
355361 ].join("\n");
356362
363 const outputSchemaJson = wantsBranch
364 ? Schema.Struct({
365 subject: Schema.String,
366 body: Schema.String,
367 branch: Schema.String,
368 })
369 : Schema.Struct({
370 subject: Schema.String,
371 body: Schema.String,
372 });
373
357374 return runCodexJson({
358375 operation: "generateCommitMessage",
359376 cwd: input.cwd,
360377 prompt,
361 outputSchemaJson: Schema.Struct({
362 subject: Schema.String,
363 body: Schema.String,
364 branch: Schema.String,
365 }),
378 outputSchemaJson,
366379 }).pipe(
367380 Effect.map(
368381 (generated) =>
369382 ({
370383 subject: sanitizeCommitSubject(generated.subject),
371384 body: generated.body.trim(),
372 branch: sanitizeFeatureBranchName(generated.branch),
385 ...("branch" in generated && typeof generated.branch === "string"
386 ? { branch: sanitizeFeatureBranchName(generated.branch) }
387 : {}),
373388 }) satisfies CommitMessageGenerationResult,
374389 ),
375390 );
......@@ -419,7 +434,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
419434
420435 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
421436 return Effect.gen(function* () {
422 const { imagePaths } = yield* materializeImageAttachments("generateBranchName", input.attachments);
437 const { imagePaths } = yield* materializeImageAttachments(
438 "generateBranchName",
439 input.attachments,
440 );
423441 const attachmentLines = (input.attachments ?? []).map(
424442 (attachment) =>
425443 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
apps/server/src/git/Layers/GitManager.test.ts+13-7
......@@ -32,7 +32,11 @@ interface FakeGitTextGeneration {
3232 branch: string | null;
3333 stagedSummary: string;
3434 stagedPatch: string;
35 }) => Effect.Effect<{ subject: string; body: string; branch: string }, TextGenerationError>;
35 includeBranch?: boolean;
36 }) => Effect.Effect<
37 { subject: string; body: string; branch?: string | undefined },
38 TextGenerationError
39 >;
3640 generatePrContent: (input: {
3741 cwd: string;
3842 baseBranch: string;
......@@ -104,11 +108,13 @@ function createBareRemote(): Effect.Effect<
104108
105109function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {
106110 const implementation: FakeGitTextGeneration = {
107 generateCommitMessage: () =>
111 generateCommitMessage: (input) =>
108112 Effect.succeed({
109113 subject: "Implement stacked git actions",
110114 body: "",
111 branch: "feature/implement-stacked-git-actions",
115 ...(input.includeBranch
116 ? { branch: "feature/implement-stacked-git-actions" }
117 : {}),
112118 }),
113119 generatePrContent: () =>
114120 Effect.succeed({
......@@ -506,13 +512,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
506512
507513 const { manager } = yield* makeManager({
508514 textGeneration: {
509 generateCommitMessage: () =>
515 generateCommitMessage: (input) =>
510516 Effect.sync(() => {
511517 generatedCount += 1;
512518 return {
513519 subject: "this should not be used",
514520 body: "",
515 branch: "feature/unused",
521 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
516522 };
517523 }),
518524 },
......@@ -566,13 +572,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
566572
567573 const { manager } = yield* makeManager({
568574 textGeneration: {
569 generateCommitMessage: () =>
575 generateCommitMessage: (input) =>
570576 Effect.sync(() => {
571577 generatedCount += 1;
572578 return {
573579 subject: "unused",
574580 body: "",
575 branch: "feature/unused",
581 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
576582 };
577583 }),
578584 },
apps/server/src/git/Layers/GitManager.ts+18-8
......@@ -85,10 +85,14 @@ function limitContext(value: string, maxChars: number): string {
8585 return `${value.slice(0, maxChars)}\n\n[truncated]`;
8686}
8787
88function sanitizeCommitMessage(generated: { subject: string; body: string; branch: string }): {
88function sanitizeCommitMessage(generated: {
8989 subject: string;
9090 body: string;
91 branch: string;
91 branch?: string | undefined;
92}): {
93 subject: string;
94 body: string;
95 branch?: string | undefined;
9296} {
9397 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
9498 const subject = rawSubject.replace(/[.]+$/g, "").trim();
......@@ -96,7 +100,7 @@ function sanitizeCommitMessage(generated: { subject: string; body: string; branc
96100 return {
97101 subject: safeSubject,
98102 body: generated.body.trim(),
99 branch: generated.branch,
103 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
100104 };
101105}
102106
......@@ -293,6 +297,8 @@ export const makeGitManager = Effect.gen(function* () {
293297 cwd: string;
294298 branch: string | null;
295299 commitMessage?: string;
300 /** When true, also produce a semantic feature branch name. */
301 includeBranch?: boolean;
296302 }) =>
297303 Effect.gen(function* () {
298304 const context = yield* gitCore.prepareCommitContext(input.cwd);
......@@ -302,11 +308,12 @@ export const makeGitManager = Effect.gen(function* () {
302308
303309 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");
304310 if (customCommit) {
305 const branch = sanitizeFeatureBranchName(customCommit.subject);
306311 return {
307312 subject: customCommit.subject,
308313 body: customCommit.body,
309 branch,
314 ...(input.includeBranch
315 ? { branch: sanitizeFeatureBranchName(customCommit.subject) }
316 : {}),
310317 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),
311318 };
312319 }
......@@ -317,14 +324,16 @@ export const makeGitManager = Effect.gen(function* () {
317324 branch: input.branch,
318325 stagedSummary: limitContext(context.stagedSummary, 8_000),
319326 stagedPatch: limitContext(context.stagedPatch, 50_000),
327 ...(input.includeBranch !== undefined ? { includeBranch: input.includeBranch } : {}),
320328 })
321329 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
322330
323 const branch = sanitizeFeatureBranchName(generated.branch);
324331 return {
325332 subject: generated.subject,
326333 body: generated.body,
327 branch,
334 ...(generated.branch !== undefined
335 ? { branch: sanitizeFeatureBranchName(generated.branch) }
336 : {}),
328337 commitMessage: formatCommitMessage(generated.subject, generated.body),
329338 };
330339 });
......@@ -456,6 +465,7 @@ export const makeGitManager = Effect.gen(function* () {
456465 cwd: input.cwd,
457466 branch: details.branch,
458467 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
468 includeBranch: true,
459469 });
460470 if (!suggestion) {
461471 return yield* gitManagerError(
......@@ -466,7 +476,7 @@ export const makeGitManager = Effect.gen(function* () {
466476
467477 return {
468478 commitMessage: suggestion.commitMessage,
469 branch: suggestion.branch,
479 branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject),
470480 };
471481 },
472482 );
apps/server/src/git/Services/TextGeneration.ts+4-1
......@@ -17,12 +17,15 @@ export interface CommitMessageGenerationInput {
1717 branch: string | null;
1818 stagedSummary: string;
1919 stagedPatch: string;
20 /** When true, the model also returns a semantic branch name for the change. */
21 includeBranch?: boolean;
2022}
2123
2224export interface CommitMessageGenerationResult {
2325 subject: string;
2426 body: string;
25 branch: string;
27 /** Only present when `includeBranch` was set on the input. */
28 branch?: string | undefined;
2629}
2730
2831export interface PrContentGenerationInput {