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>(...@@ -189,15 +189,15 @@ function withFakeCodexEnv<A, E, R>(
189const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd());189const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd());
190190
191it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {191it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
192 it.effect("generates and sanitizes commit messages", () =>192 it.effect("generates and sanitizes commit messages without branch by default", () =>
193 withFakeCodexEnv(193 withFakeCodexEnv(
194 {194 {
195 output: JSON.stringify({195 output: JSON.stringify({
196 subject:196 subject:
197 " Add important change to the system with too much detail and a trailing period.\nsecondary line",197 " Add important change to the system with too much detail and a trailing period.\nsecondary line",
198 body: "\n- added migration\n- updated tests\n",198 body: "\n- added migration\n- updated tests\n",
199 branch: "fix/important-system-change",
200 }),199 }),
200 stdinMustNotContain: "branch must be a short semantic git branch fragment",
201 },201 },
202 Effect.gen(function* () {202 Effect.gen(function* () {
203 const textGeneration = yield* TextGeneration;203 const textGeneration = yield* TextGeneration;
...@@ -212,6 +212,33 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -212,6 +212,33 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
212 expect(generated.subject.length).toBeLessThanOrEqual(72);212 expect(generated.subject.length).toBeLessThanOrEqual(72);
213 expect(generated.subject.endsWith(".")).toBe(false);213 expect(generated.subject.endsWith(".")).toBe(false);
214 expect(generated.body).toBe("- added migration\n- updated tests");214 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");
215 expect(generated.branch).toBe("feature/fix/important-system-change");242 expect(generated.branch).toBe("feature/fix/important-system-change");
216 }),243 }),
217 ),244 ),
apps/server/src/git/Layers/CodexTextGeneration.ts+27-9
...@@ -336,13 +336,19 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -336,13 +336,19 @@ const makeCodexTextGeneration = Effect.gen(function* () {
336 });336 });
337337
338 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {338 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
339 const wantsBranch = input.includeBranch === true;
340
339 const prompt = [341 const prompt = [
340 "You write concise git commit messages.",342 "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.",
342 "Rules:",346 "Rules:",
343 "- subject must be imperative, <= 72 chars, and no trailing period",347 "- subject must be imperative, <= 72 chars, and no trailing period",
344 "- body can be empty string or short bullet points",348 "- 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 : []),
346 "- capture the primary user-visible or developer-visible change",352 "- capture the primary user-visible or developer-visible change",
347 "",353 "",
348 `Branch: ${input.branch ?? "(detached)"}`,354 `Branch: ${input.branch ?? "(detached)"}`,
...@@ -354,22 +360,31 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -354,22 +360,31 @@ const makeCodexTextGeneration = Effect.gen(function* () {
354 limitSection(input.stagedPatch, 40_000),360 limitSection(input.stagedPatch, 40_000),
355 ].join("\n");361 ].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
357 return runCodexJson({374 return runCodexJson({
358 operation: "generateCommitMessage",375 operation: "generateCommitMessage",
359 cwd: input.cwd,376 cwd: input.cwd,
360 prompt,377 prompt,
361 outputSchemaJson: Schema.Struct({378 outputSchemaJson,
362 subject: Schema.String,
363 body: Schema.String,
364 branch: Schema.String,
365 }),
366 }).pipe(379 }).pipe(
367 Effect.map(380 Effect.map(
368 (generated) =>381 (generated) =>
369 ({382 ({
370 subject: sanitizeCommitSubject(generated.subject),383 subject: sanitizeCommitSubject(generated.subject),
371 body: generated.body.trim(),384 body: generated.body.trim(),
372 branch: sanitizeFeatureBranchName(generated.branch),385 ...("branch" in generated && typeof generated.branch === "string"
386 ? { branch: sanitizeFeatureBranchName(generated.branch) }
387 : {}),
373 }) satisfies CommitMessageGenerationResult,388 }) satisfies CommitMessageGenerationResult,
374 ),389 ),
375 );390 );
...@@ -419,7 +434,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -419,7 +434,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
419434
420 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {435 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
421 return Effect.gen(function* () {436 return Effect.gen(function* () {
422 const { imagePaths } = yield* materializeImageAttachments("generateBranchName", input.attachments);437 const { imagePaths } = yield* materializeImageAttachments(
438 "generateBranchName",
439 input.attachments,
440 );
423 const attachmentLines = (input.attachments ?? []).map(441 const attachmentLines = (input.attachments ?? []).map(
424 (attachment) =>442 (attachment) =>
425 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,443 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
apps/server/src/git/Layers/GitManager.test.ts+13-7
...@@ -32,7 +32,11 @@ interface FakeGitTextGeneration {...@@ -32,7 +32,11 @@ interface FakeGitTextGeneration {
32 branch: string | null;32 branch: string | null;
33 stagedSummary: string;33 stagedSummary: string;
34 stagedPatch: string;34 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 >;
36 generatePrContent: (input: {40 generatePrContent: (input: {
37 cwd: string;41 cwd: string;
38 baseBranch: string;42 baseBranch: string;
...@@ -104,11 +108,13 @@ function createBareRemote(): Effect.Effect<...@@ -104,11 +108,13 @@ function createBareRemote(): Effect.Effect<
104108
105function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {109function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {
106 const implementation: FakeGitTextGeneration = {110 const implementation: FakeGitTextGeneration = {
107 generateCommitMessage: () =>111 generateCommitMessage: (input) =>
108 Effect.succeed({112 Effect.succeed({
109 subject: "Implement stacked git actions",113 subject: "Implement stacked git actions",
110 body: "",114 body: "",
111 branch: "feature/implement-stacked-git-actions",115 ...(input.includeBranch
116 ? { branch: "feature/implement-stacked-git-actions" }
117 : {}),
112 }),118 }),
113 generatePrContent: () =>119 generatePrContent: () =>
114 Effect.succeed({120 Effect.succeed({
...@@ -506,13 +512,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -506,13 +512,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
506512
507 const { manager } = yield* makeManager({513 const { manager } = yield* makeManager({
508 textGeneration: {514 textGeneration: {
509 generateCommitMessage: () =>515 generateCommitMessage: (input) =>
510 Effect.sync(() => {516 Effect.sync(() => {
511 generatedCount += 1;517 generatedCount += 1;
512 return {518 return {
513 subject: "this should not be used",519 subject: "this should not be used",
514 body: "",520 body: "",
515 branch: "feature/unused",521 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
516 };522 };
517 }),523 }),
518 },524 },
...@@ -566,13 +572,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -566,13 +572,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
566572
567 const { manager } = yield* makeManager({573 const { manager } = yield* makeManager({
568 textGeneration: {574 textGeneration: {
569 generateCommitMessage: () =>575 generateCommitMessage: (input) =>
570 Effect.sync(() => {576 Effect.sync(() => {
571 generatedCount += 1;577 generatedCount += 1;
572 return {578 return {
573 subject: "unused",579 subject: "unused",
574 body: "",580 body: "",
575 branch: "feature/unused",581 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
576 };582 };
577 }),583 }),
578 },584 },
apps/server/src/git/Layers/GitManager.ts+18-8
...@@ -85,10 +85,14 @@ function limitContext(value: string, maxChars: number): string {...@@ -85,10 +85,14 @@ function limitContext(value: string, maxChars: number): string {
85 return `${value.slice(0, maxChars)}\n\n[truncated]`;85 return `${value.slice(0, maxChars)}\n\n[truncated]`;
86}86}
8787
88function sanitizeCommitMessage(generated: { subject: string; body: string; branch: string }): {88function sanitizeCommitMessage(generated: {
89 subject: string;89 subject: string;
90 body: string;90 body: string;
91 branch: string;91 branch?: string | undefined;
92}): {
93 subject: string;
94 body: string;
95 branch?: string | undefined;
92} {96} {
93 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";97 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
94 const subject = rawSubject.replace(/[.]+$/g, "").trim();98 const subject = rawSubject.replace(/[.]+$/g, "").trim();
...@@ -96,7 +100,7 @@ function sanitizeCommitMessage(generated: { subject: string; body: string; branc...@@ -96,7 +100,7 @@ function sanitizeCommitMessage(generated: { subject: string; body: string; branc
96 return {100 return {
97 subject: safeSubject,101 subject: safeSubject,
98 body: generated.body.trim(),102 body: generated.body.trim(),
99 branch: generated.branch,103 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
100 };104 };
101}105}
102106
...@@ -293,6 +297,8 @@ export const makeGitManager = Effect.gen(function* () {...@@ -293,6 +297,8 @@ export const makeGitManager = Effect.gen(function* () {
293 cwd: string;297 cwd: string;
294 branch: string | null;298 branch: string | null;
295 commitMessage?: string;299 commitMessage?: string;
300 /** When true, also produce a semantic feature branch name. */
301 includeBranch?: boolean;
296 }) =>302 }) =>
297 Effect.gen(function* () {303 Effect.gen(function* () {
298 const context = yield* gitCore.prepareCommitContext(input.cwd);304 const context = yield* gitCore.prepareCommitContext(input.cwd);
...@@ -302,11 +308,12 @@ export const makeGitManager = Effect.gen(function* () {...@@ -302,11 +308,12 @@ export const makeGitManager = Effect.gen(function* () {
302308
303 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");309 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");
304 if (customCommit) {310 if (customCommit) {
305 const branch = sanitizeFeatureBranchName(customCommit.subject);
306 return {311 return {
307 subject: customCommit.subject,312 subject: customCommit.subject,
308 body: customCommit.body,313 body: customCommit.body,
309 branch,314 ...(input.includeBranch
315 ? { branch: sanitizeFeatureBranchName(customCommit.subject) }
316 : {}),
310 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),317 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),
311 };318 };
312 }319 }
...@@ -317,14 +324,16 @@ export const makeGitManager = Effect.gen(function* () {...@@ -317,14 +324,16 @@ export const makeGitManager = Effect.gen(function* () {
317 branch: input.branch,324 branch: input.branch,
318 stagedSummary: limitContext(context.stagedSummary, 8_000),325 stagedSummary: limitContext(context.stagedSummary, 8_000),
319 stagedPatch: limitContext(context.stagedPatch, 50_000),326 stagedPatch: limitContext(context.stagedPatch, 50_000),
327 ...(input.includeBranch !== undefined ? { includeBranch: input.includeBranch } : {}),
320 })328 })
321 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));329 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
322330
323 const branch = sanitizeFeatureBranchName(generated.branch);
324 return {331 return {
325 subject: generated.subject,332 subject: generated.subject,
326 body: generated.body,333 body: generated.body,
327 branch,334 ...(generated.branch !== undefined
335 ? { branch: sanitizeFeatureBranchName(generated.branch) }
336 : {}),
328 commitMessage: formatCommitMessage(generated.subject, generated.body),337 commitMessage: formatCommitMessage(generated.subject, generated.body),
329 };338 };
330 });339 });
...@@ -456,6 +465,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -456,6 +465,7 @@ export const makeGitManager = Effect.gen(function* () {
456 cwd: input.cwd,465 cwd: input.cwd,
457 branch: details.branch,466 branch: details.branch,
458 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),467 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
468 includeBranch: true,
459 });469 });
460 if (!suggestion) {470 if (!suggestion) {
461 return yield* gitManagerError(471 return yield* gitManagerError(
...@@ -466,7 +476,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -466,7 +476,7 @@ export const makeGitManager = Effect.gen(function* () {
466476
467 return {477 return {
468 commitMessage: suggestion.commitMessage,478 commitMessage: suggestion.commitMessage,
469 branch: suggestion.branch,479 branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject),
470 };480 };
471 },481 },
472 );482 );
apps/server/src/git/Services/TextGeneration.ts+4-1
...@@ -17,12 +17,15 @@ export interface CommitMessageGenerationInput {...@@ -17,12 +17,15 @@ export interface CommitMessageGenerationInput {
17 branch: string | null;17 branch: string | null;
18 stagedSummary: string;18 stagedSummary: string;
19 stagedPatch: string;19 stagedPatch: string;
20 /** When true, the model also returns a semantic branch name for the change. */
21 includeBranch?: boolean;
20}22}
2123
22export interface CommitMessageGenerationResult {24export interface CommitMessageGenerationResult {
23 subject: string;25 subject: string;
24 body: string;26 body: string;
25 branch: string;27 /** Only present when `includeBranch` was set on the input. */
28 branch?: string | undefined;
26}29}
2730
28export interface PrContentGenerationInput {31export interface PrContentGenerationInput {