authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 00:54:34-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 20:46:05-08:00
logc34a4c69e5d58f48f68c53ebed22183905a9db15
tree3ec387f6d591412175eb056901f5e8c84bdc813a
parent2f6cac0cf0c7424236e3aaf04c2485e72fe1fe5c

Add AI-generated semantic branch naming for git commits

Replace date-based auto feature branch names (feature/stacked-YYYYMMDD) with semantically meaningful names derived from AI-generated commit messages. The text generation prompt now returns a branch field alongside subject and body, and a new suggestCommitAndBranch endpoint exposes this to the web client for the default-branch checkout flow. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

18 files changed, 388 insertions(+), 49 deletions(-)

apps/server/src/git/Layers/CodexTextGeneration.test.ts+2
......@@ -196,6 +196,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
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",
199200 }),
200201 },
201202 Effect.gen(function* () {
......@@ -211,6 +212,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
211212 expect(generated.subject.length).toBeLessThanOrEqual(72);
212213 expect(generated.subject.endsWith(".")).toBe(false);
213214 expect(generated.body).toBe("- added migration\n- updated tests");
215 expect(generated.branch).toBe("feature/fix/important-system-change");
214216 }),
215217 ),
216218 );
apps/server/src/git/Layers/CodexTextGeneration.ts+12-1
......@@ -111,6 +111,14 @@ function sanitizeBranchName(raw: string): string {
111111 return branchFragment.length > 0 ? branchFragment : "update";
112112}
113113
114function sanitizeFeatureBranchName(raw: string): string {
115 const sanitized = sanitizeBranchName(raw);
116 if (sanitized.includes("/")) {
117 return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`;
118 }
119 return `feature/${sanitized}`;
120}
121
114122const makeCodexTextGeneration = Effect.gen(function* () {
115123 const fileSystem = yield* FileSystem.FileSystem;
116124 const path = yield* Path.Path;
......@@ -330,10 +338,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
330338 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
331339 const prompt = [
332340 "You write concise git commit messages.",
333 "Return a JSON object with keys: subject, body.",
341 "Return a JSON object with keys: subject, body, branch.",
334342 "Rules:",
335343 "- subject must be imperative, <= 72 chars, and no trailing period",
336344 "- body can be empty string or short bullet points",
345 "- branch must be a short semantic git branch fragment for this change",
337346 "- capture the primary user-visible or developer-visible change",
338347 "",
339348 `Branch: ${input.branch ?? "(detached)"}`,
......@@ -352,6 +361,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
352361 outputSchemaJson: Schema.Struct({
353362 subject: Schema.String,
354363 body: Schema.String,
364 branch: Schema.String,
355365 }),
356366 }).pipe(
357367 Effect.map(
......@@ -359,6 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
359369 ({
360370 subject: sanitizeCommitSubject(generated.subject),
361371 body: generated.body.trim(),
372 branch: sanitizeFeatureBranchName(generated.branch),
362373 }) satisfies CommitMessageGenerationResult,
363374 ),
364375 );
apps/server/src/git/Layers/GitManager.test.ts+81-1
......@@ -32,7 +32,7 @@ interface FakeGitTextGeneration {
3232 branch: string | null;
3333 stagedSummary: string;
3434 stagedPatch: string;
35 }) => Effect.Effect<{ subject: string; body: string }, TextGenerationError>;
35 }) => Effect.Effect<{ subject: string; body: string; branch: string }, TextGenerationError>;
3636 generatePrContent: (input: {
3737 cwd: string;
3838 baseBranch: string;
......@@ -108,6 +108,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
108108 Effect.succeed({
109109 subject: "Implement stacked git actions",
110110 body: "",
111 branch: "feature/implement-stacked-git-actions",
111112 }),
112113 generatePrContent: () =>
113114 Effect.succeed({
......@@ -288,6 +289,16 @@ function runStackedAction(
288289 return manager.runStackedAction(input);
289290}
290291
292function suggestCommitAndBranch(
293 manager: GitManagerShape,
294 input: {
295 cwd: string;
296 commitMessage?: string;
297 },
298) {
299 return manager.suggestCommitAndBranch(input);
300}
301
291302function makeManager(input?: {
292303 ghScenario?: FakeGhScenario;
293304 textGeneration?: Partial<FakeGitTextGeneration>;
......@@ -501,6 +512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
501512 return {
502513 subject: "this should not be used",
503514 body: "",
515 branch: "feature/unused",
504516 };
505517 }),
506518 },
......@@ -527,6 +539,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
527539 }),
528540 );
529541
542 it.effect("suggests commit message and semantic branch for dirty worktree", () =>
543 Effect.gen(function* () {
544 const repoDir = yield* makeTempDir("t3code-git-manager-");
545 yield* initRepo(repoDir);
546 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nsuggest\n");
547
548 const { manager } = yield* makeManager();
549 const suggestion = yield* suggestCommitAndBranch(manager, {
550 cwd: repoDir,
551 });
552
553 expect(suggestion).toEqual({
554 commitMessage: "Implement stacked git actions",
555 branch: "feature/implement-stacked-git-actions",
556 });
557 }),
558 );
559
560 it.effect("suggests branch from custom commit message without AI generation", () =>
561 Effect.gen(function* () {
562 const repoDir = yield* makeTempDir("t3code-git-manager-");
563 yield* initRepo(repoDir);
564 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-suggest\n");
565 let generatedCount = 0;
566
567 const { manager } = yield* makeManager({
568 textGeneration: {
569 generateCommitMessage: () =>
570 Effect.sync(() => {
571 generatedCount += 1;
572 return {
573 subject: "unused",
574 body: "",
575 branch: "feature/unused",
576 };
577 }),
578 },
579 });
580 const suggestion = yield* suggestCommitAndBranch(manager, {
581 cwd: repoDir,
582 commitMessage: "feat: custom summary line\n\n- details from user",
583 });
584
585 expect(suggestion).toEqual({
586 commitMessage: "feat: custom summary line\n\n- details from user",
587 branch: "feature/feat-custom-summary-line",
588 });
589 expect(generatedCount).toBe(0);
590 }),
591 );
592
530593 it.effect("skips commit when there are no uncommitted changes", () =>
531594 Effect.gen(function* () {
532595 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -544,6 +607,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
544607 }),
545608 );
546609
610 it.effect("returns a clear error when suggesting from clean worktree", () =>
611 Effect.gen(function* () {
612 const repoDir = yield* makeTempDir("t3code-git-manager-");
613 yield* initRepo(repoDir);
614
615 const { manager } = yield* makeManager();
616 const errorMessage = yield* suggestCommitAndBranch(manager, {
617 cwd: repoDir,
618 }).pipe(
619 Effect.flip,
620 Effect.map((error) => error.message),
621 );
622
623 expect(errorMessage).toContain("no changes to commit");
624 }),
625 );
626
547627 it.effect("commits and pushes with upstream auto-setup when needed", () =>
548628 Effect.gen(function* () {
549629 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+107-16
......@@ -85,9 +85,10 @@ 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 }): {
88function sanitizeCommitMessage(generated: { subject: string; body: string; branch: string }): {
8989 subject: string;
9090 body: string;
91 branch: string;
9192} {
9293 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
9394 const subject = rawSubject.replace(/[.]+$/g, "").trim();
......@@ -95,9 +96,44 @@ function sanitizeCommitMessage(generated: { subject: string; body: string }): {
9596 return {
9697 subject: safeSubject,
9798 body: generated.body.trim(),
99 branch: generated.branch,
98100 };
99101}
100102
103function sanitizeBranchFragment(raw: string): string {
104 const normalized = raw
105 .trim()
106 .toLowerCase()
107 .replace(/['"`]/g, "")
108 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
109
110 const branchFragment = normalized
111 .replace(/[^a-z0-9/_-]+/g, "-")
112 .replace(/\/+/g, "/")
113 .replace(/-+/g, "-")
114 .replace(/^[./_-]+|[./_-]+$/g, "")
115 .slice(0, 64)
116 .replace(/[./_-]+$/g, "");
117
118 return branchFragment.length > 0 ? branchFragment : "update";
119}
120
121function sanitizeFeatureBranchName(raw: string): string {
122 const sanitized = sanitizeBranchFragment(raw);
123 if (sanitized.includes("/")) {
124 return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`;
125 }
126 return `feature/${sanitized}`;
127}
128
129function formatCommitMessage(subject: string, body: string): string {
130 const trimmedBody = body.trim();
131 if (trimmedBody.length === 0) {
132 return subject;
133 }
134 return `${subject}\n\n${trimmedBody}`;
135}
136
101137function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null {
102138 const normalized = raw.replace(/\r\n/g, "\n").trim();
103139 if (normalized.length === 0) {
......@@ -253,30 +289,62 @@ export const makeGitManager = Effect.gen(function* () {
253289 return "main";
254290 });
255291
256 const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) =>
292 const resolveCommitAndBranchSuggestion = (input: {
293 cwd: string;
294 branch: string | null;
295 commitMessage?: string;
296 }) =>
257297 Effect.gen(function* () {
258 const context = yield* gitCore.prepareCommitContext(cwd);
298 const context = yield* gitCore.prepareCommitContext(input.cwd);
259299 if (!context) {
260 return { status: "skipped_no_changes" as const };
300 return null;
261301 }
262302
263 let generated = parseCustomCommitMessage(commitMessage ?? "");
264 if (!generated) {
265 generated = yield* textGeneration
266 .generateCommitMessage({
267 cwd,
268 branch,
269 stagedSummary: limitContext(context.stagedSummary, 8_000),
270 stagedPatch: limitContext(context.stagedPatch, 50_000),
271 })
272 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
303 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");
304 if (customCommit) {
305 const branch = sanitizeFeatureBranchName(customCommit.subject);
306 return {
307 subject: customCommit.subject,
308 body: customCommit.body,
309 branch,
310 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),
311 };
312 }
313
314 const generated = yield* textGeneration
315 .generateCommitMessage({
316 cwd: input.cwd,
317 branch: input.branch,
318 stagedSummary: limitContext(context.stagedSummary, 8_000),
319 stagedPatch: limitContext(context.stagedPatch, 50_000),
320 })
321 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
322
323 const branch = sanitizeFeatureBranchName(generated.branch);
324 return {
325 subject: generated.subject,
326 body: generated.body,
327 branch,
328 commitMessage: formatCommitMessage(generated.subject, generated.body),
329 };
330 });
331
332 const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) =>
333 Effect.gen(function* () {
334 const suggestion = yield* resolveCommitAndBranchSuggestion({
335 cwd,
336 branch,
337 ...(commitMessage ? { commitMessage } : {}),
338 });
339 if (!suggestion) {
340 return { status: "skipped_no_changes" as const };
273341 }
274342
275 const { commitSha } = yield* gitCore.commit(cwd, generated.subject, generated.body);
343 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body);
276344 return {
277345 status: "created" as const,
278346 commitSha,
279 subject: generated.subject,
347 subject: suggestion.subject,
280348 };
281349 });
282350
......@@ -381,6 +449,28 @@ export const makeGitManager = Effect.gen(function* () {
381449 };
382450 });
383451
452 const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced(
453 function* (input) {
454 const details = yield* gitCore.statusDetails(input.cwd);
455 const suggestion = yield* resolveCommitAndBranchSuggestion({
456 cwd: input.cwd,
457 branch: details.branch,
458 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
459 });
460 if (!suggestion) {
461 return yield* gitManagerError(
462 "suggestCommitAndBranch",
463 "Cannot suggest a commit and branch because there are no changes to commit.",
464 );
465 }
466
467 return {
468 commitMessage: suggestion.commitMessage,
469 branch: suggestion.branch,
470 };
471 },
472 );
473
384474 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(
385475 function* (input) {
386476 const wantsPush = input.action !== "commit";
......@@ -419,6 +509,7 @@ export const makeGitManager = Effect.gen(function* () {
419509 return {
420510 status,
421511 runStackedAction,
512 suggestCommitAndBranch,
422513 } satisfies GitManagerShape;
423514});
424515
apps/server/src/git/Services/GitManager.ts+9
......@@ -9,6 +9,8 @@
99import {
1010 GitRunStackedActionInput,
1111 GitRunStackedActionResult,
12 GitSuggestCommitAndBranchInput,
13 GitSuggestCommitAndBranchResult,
1214 GitStatusInput,
1315 GitStatusResult,
1416} from "@t3tools/contracts";
......@@ -33,6 +35,13 @@ export interface GitManagerShape {
3335 readonly runStackedAction: (
3436 input: GitRunStackedActionInput,
3537 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
38
39 /**
40 * Suggest a semantic branch name and commit message for current staged changes.
41 */
42 readonly suggestCommitAndBranch: (
43 input: GitSuggestCommitAndBranchInput,
44 ) => Effect.Effect<GitSuggestCommitAndBranchResult, GitManagerServiceError>;
3645}
3746
3847/**
apps/server/src/git/Services/TextGeneration.ts+1
......@@ -22,6 +22,7 @@ export interface CommitMessageGenerationInput {
2222export interface CommitMessageGenerationResult {
2323 subject: string;
2424 body: string;
25 branch: string;
2526}
2627
2728export interface PrContentGenerationInput {
apps/server/src/wsServer.test.ts+31-1
......@@ -1555,7 +1555,8 @@ describe("WebSocket Server", () => {
15551555
15561556 const status = vi.fn(() => Effect.succeed(statusResult));
15571557 const runStackedAction = vi.fn(() => Effect.void as any);
1558 const gitManager: GitManagerShape = { status, runStackedAction };
1558 const suggestCommitAndBranch = vi.fn(() => Effect.void as any);
1559 const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch };
15591560
15601561 server = await createTestServer({ cwd: "/test", gitManager });
15611562 const addr = server.address();
......@@ -1585,6 +1586,7 @@ describe("WebSocket Server", () => {
15851586 const gitManager: GitManagerShape = {
15861587 status: vi.fn(() => Effect.void as any),
15871588 runStackedAction,
1589 suggestCommitAndBranch: vi.fn(() => Effect.void as any),
15881590 };
15891591
15901592 server = await createTestServer({ cwd: "/test", gitManager });
......@@ -1607,6 +1609,34 @@ describe("WebSocket Server", () => {
16071609 });
16081610 });
16091611
1612 it("supports git.suggestCommitAndBranch over websocket", async () => {
1613 const suggestCommitAndBranchResult = {
1614 commitMessage: "feat: improve git actions",
1615 branch: "feature/improve-git-actions",
1616 };
1617 const suggestCommitAndBranch = vi.fn(() => Effect.succeed(suggestCommitAndBranchResult));
1618 const gitManager: GitManagerShape = {
1619 status: vi.fn(() => Effect.void as any),
1620 runStackedAction: vi.fn(() => Effect.void as any),
1621 suggestCommitAndBranch,
1622 };
1623
1624 server = await createTestServer({ cwd: "/test", gitManager });
1625 const addr = server.address();
1626 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1627
1628 const ws = await connectWs(port);
1629 connections.push(ws);
1630 await waitForMessage(ws);
1631
1632 const response = await sendRequest(ws, WS_METHODS.gitSuggestCommitAndBranch, {
1633 cwd: "/test",
1634 });
1635 expect(response.error).toBeUndefined();
1636 expect(response.result).toEqual(suggestCommitAndBranchResult);
1637 expect(suggestCommitAndBranch).toHaveBeenCalledWith({ cwd: "/test" });
1638 });
1639
16101640 it("rejects websocket connections without a valid auth token", async () => {
16111641 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
16121642 const addr = server.address();
apps/server/src/wsServer.ts+5
......@@ -668,6 +668,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
668668 return yield* gitManager.runStackedAction(body);
669669 }
670670
671 case WS_METHODS.gitSuggestCommitAndBranch: {
672 const body = stripRequestTag(request.body);
673 return yield* gitManager.suggestCommitAndBranch(body);
674 }
675
671676 case WS_METHODS.gitListBranches: {
672677 const body = stripRequestTag(request.body);
673678 return yield* git.listBranches(body);
apps/web/src/components/GitActionsControl.logic.test.ts+16-8
......@@ -910,18 +910,26 @@ describe("summarizeGitResult", () => {
910910});
911911
912912describe("resolveAutoFeatureBranchName", () => {
913 const day = new Date("2026-03-01T12:00:00.000Z");
913 it("uses semantic preferred branch names when available", () => {
914 const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy");
915 assert.equal(branch, "feature/fix-toast-copy");
916 });
914917
915 it("returns the base auto feature branch name when available", () => {
916 const branch = resolveAutoFeatureBranchName(["main", "feature/other"], day);
917 assert.equal(branch, "feature/stacked-20260301");
918 it("normalizes preferred names that already include a branch namespace", () => {
919 const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions");
920 assert.equal(branch, "feature/refine-toolbar-actions");
918921 });
919922
920 it("increments suffix when the base name already exists", () => {
923 it("increments suffix when the preferred branch name already exists", () => {
921924 const branch = resolveAutoFeatureBranchName(
922 ["main", "feature/stacked-20260301", "feature/stacked-20260301-2"],
923 day,
925 ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"],
926 "fix toast copy",
924927 );
925 assert.equal(branch, "feature/stacked-20260301-3");
928 assert.equal(branch, "feature/fix-toast-copy-3");
929 });
930
931 it("falls back to feature/update when no preferred name is provided", () => {
932 const branch = resolveAutoFeatureBranchName(["main"]);
933 assert.equal(branch, "feature/update");
926934 });
927935});
apps/web/src/components/GitActionsControl.logic.ts+31-13
......@@ -35,7 +35,7 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";
3535
3636const SHORT_SHA_LENGTH = 7;
3737const TOAST_DESCRIPTION_MAX = 72;
38const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked";
38const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
3939
4040function shortenSha(sha: string | undefined): string | null {
4141 if (!sha) return null;
......@@ -321,29 +321,47 @@ export function resolveDefaultBranchActionDialogCopy(input: {
321321 };
322322}
323323
324function formatDateForBranch(now: Date): string {
325 const year = now.getUTCFullYear();
326 const month = String(now.getUTCMonth() + 1).padStart(2, "0");
327 const day = String(now.getUTCDate()).padStart(2, "0");
328 return `${year}${month}${day}`;
324function sanitizeBranchFragment(raw: string): string {
325 const normalized = raw
326 .trim()
327 .toLowerCase()
328 .replace(/['"`]/g, "")
329 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
330
331 const branchFragment = normalized
332 .replace(/[^a-z0-9/_-]+/g, "-")
333 .replace(/\/+/g, "/")
334 .replace(/-+/g, "-")
335 .replace(/^[./_-]+|[./_-]+$/g, "")
336 .slice(0, 64)
337 .replace(/[./_-]+$/g, "");
338
339 return branchFragment.length > 0 ? branchFragment : "update";
329340}
330341
331342export function resolveAutoFeatureBranchName(
332343 existingBranchNames: readonly string[],
333 now = new Date(),
344 preferredBranch?: string,
334345): string {
335 const daySlug = formatDateForBranch(now);
336 const base = `${AUTO_FEATURE_BRANCH_PREFIX}-${daySlug}`;
346 const preferred = preferredBranch?.trim();
347 const normalized = sanitizeBranchFragment(
348 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
349 );
350 const resolvedBase = normalized.includes("/")
351 ? normalized.startsWith("feature/")
352 ? normalized
353 : `feature/${normalized}`
354 : `feature/${normalized}`;
337355 const existingNames = new Set(existingBranchNames);
338356
339 if (!existingNames.has(base)) {
340 return base;
357 if (!existingNames.has(resolvedBase)) {
358 return resolvedBase;
341359 }
342360
343361 let suffix = 2;
344 while (existingNames.has(`${base}-${suffix}`)) {
362 while (existingNames.has(`${resolvedBase}-${suffix}`)) {
345363 suffix += 1;
346364 }
347365
348 return `${base}-${suffix}`;
366 return `${resolvedBase}-${suffix}`;
349367}
apps/web/src/components/GitActionsControl.tsx+42-8
......@@ -39,6 +39,7 @@ import {
3939 gitMutationKeys,
4040 gitPullMutationOptions,
4141 gitRunStackedActionMutationOptions,
42 gitSuggestCommitAndBranchMutationOptions,
4243 gitStatusQueryOptions,
4344 invalidateGitQueries,
4445} from "~/lib/gitReactQuery";
......@@ -172,6 +173,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
172173 const runImmediateGitActionMutation = useMutation(
173174 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
174175 );
176 const suggestCommitAndBranchMutation = useMutation(
177 gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }),
178 );
175179 const createBranchAndCheckoutMutation = useMutation(
176180 gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }),
177181 );
......@@ -179,11 +183,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
179183
180184 const isRunStackedActionRunning =
181185 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
186 const isSuggestCommitAndBranchRunning =
187 useIsMutating({ mutationKey: gitMutationKeys.suggestCommitAndBranch(gitCwd) }) > 0;
182188 const isCreateBranchAndCheckoutRunning =
183189 useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0;
184190 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
185191 const isGitActionRunning =
186 isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning;
192 isRunStackedActionRunning ||
193 isSuggestCommitAndBranchRunning ||
194 isCreateBranchAndCheckoutRunning ||
195 isPullRunning;
187196 const isDefaultBranch = useMemo(() => {
188197 const branchName = gitStatusForActions?.branch;
189198 if (!branchName) return false;
......@@ -443,20 +452,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
443452 forcePushOnlyProgress?: boolean;
444453 onConfirmed?: () => void;
445454 }) => {
446 const branchName = resolveAutoFeatureBranchName(
447 branchList?.branches.map((branch) => branch.name) ?? [],
448 );
455 const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? [];
456 const providedCommitMessage = actionParams.commitMessage?.trim() || undefined;
457 const shouldSuggestCommitAndBranch =
458 !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges;
449459
450 const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName);
451460 const checkoutToastId = toastManager.add({
452461 type: "loading",
453 title: `Creating ${branchName}...`,
462 title: "Preparing feature branch...",
454463 timeout: 0,
455464 data: threadToastData,
456465 });
457466
458 void checkoutPromise
459 .then(() => {
467 const suggestionPromise = shouldSuggestCommitAndBranch
468 ? suggestCommitAndBranchMutation.mutateAsync({}).catch(() => null)
469 : Promise.resolve(null);
470
471 void suggestionPromise
472 .then((suggestion) => {
473 const preferredBranch =
474 suggestion?.branch ??
475 (providedCommitMessage ? providedCommitMessage.split(/\r?\n/g)[0]?.trim() : undefined);
476 const branchName = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
477 const resolvedCommitMessage = suggestion?.commitMessage ?? providedCommitMessage;
478
479 toastManager.update(checkoutToastId, {
480 type: "loading",
481 title: `Creating ${branchName}...`,
482 timeout: 0,
483 data: threadToastData,
484 });
485
486 return createBranchAndCheckoutMutation.mutateAsync(branchName).then(() => ({
487 branchName,
488 resolvedCommitMessage,
489 }));
490 })
491 .then(({ branchName, resolvedCommitMessage }) => {
460492 const statusOverride = gitStatusForActions
461493 ? {
462494 ...gitStatusForActions,
......@@ -469,6 +501,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
469501 : null;
470502 return runGitActionWithToast({
471503 ...actionParams,
504 ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}),
472505 skipDefaultBranchPrompt: true,
473506 statusOverride,
474507 isDefaultBranchOverride: false,
......@@ -489,6 +522,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
489522 createBranchAndCheckoutMutation,
490523 gitStatusForActions,
491524 runGitActionWithToast,
525 suggestCommitAndBranchMutation,
492526 threadToastData,
493527 ],
494528 );
apps/web/src/lib/gitReactQuery.test.ts+12
......@@ -4,6 +4,7 @@ import {
44 gitMutationKeys,
55 gitPullMutationOptions,
66 gitRunStackedActionMutationOptions,
7 gitSuggestCommitAndBranchMutationOptions,
78} from "./gitReactQuery";
89
910describe("gitMutationKeys", () => {
......@@ -16,6 +17,12 @@ describe("gitMutationKeys", () => {
1617 it("scopes pull keys by cwd", () => {
1718 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
1819 });
20
21 it("scopes suggest-commit-and-branch keys by cwd", () => {
22 expect(gitMutationKeys.suggestCommitAndBranch("/repo/a")).not.toEqual(
23 gitMutationKeys.suggestCommitAndBranch("/repo/b"),
24 );
25 });
1926});
2027
2128describe("git mutation options", () => {
......@@ -30,4 +37,9 @@ describe("git mutation options", () => {
3037 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
3138 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
3239 });
40
41 it("attaches cwd-scoped mutation key for suggestCommitAndBranch", () => {
42 const options = gitSuggestCommitAndBranchMutationOptions({ cwd: "/repo/a" });
43 expect(options.mutationKey).toEqual(gitMutationKeys.suggestCommitAndBranch("/repo/a"));
44 });
3345});
apps/web/src/lib/gitReactQuery.ts+16
......@@ -19,6 +19,8 @@ export const gitMutationKeys = {
1919 createBranchAndCheckout: (cwd: string | null) =>
2020 ["git", "mutation", "create-branch-and-checkout", cwd] as const,
2121 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,
22 suggestCommitAndBranch: (cwd: string | null) =>
23 ["git", "mutation", "suggest-commit-and-branch", cwd] as const,
2224 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
2325};
2426
......@@ -134,6 +136,20 @@ export function gitRunStackedActionMutationOptions(input: {
134136 });
135137}
136138
139export function gitSuggestCommitAndBranchMutationOptions(input: { cwd: string | null }) {
140 return mutationOptions({
141 mutationKey: gitMutationKeys.suggestCommitAndBranch(input.cwd),
142 mutationFn: async ({ commitMessage }: { commitMessage?: string }) => {
143 const api = ensureNativeApi();
144 if (!input.cwd) throw new Error("Git action is unavailable.");
145 return api.git.suggestCommitAndBranch({
146 cwd: input.cwd,
147 ...(commitMessage ? { commitMessage } : {}),
148 });
149 },
150 });
151}
152
137153export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
138154 return mutationOptions({
139155 mutationKey: gitMutationKeys.pull(input.cwd),
apps/web/src/wsNativeApi.test.ts+1-1
......@@ -6,6 +6,7 @@ import {
66 ProjectId,
77 ThreadId,
88 WS_CHANNELS,
9 WS_METHODS,
910} from "@t3tools/contracts";
1011import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1112
......@@ -376,5 +377,4 @@ describe("wsNativeApi", () => {
376377 { x: 20, y: 30 },
377378 );
378379 });
379
380380});
apps/web/src/wsNativeApi.ts+2
......@@ -163,6 +163,8 @@ export function createWsNativeApi(): NativeApi {
163163 pull: (input) => transport.request(WS_METHODS.gitPull, input),
164164 status: (input) => transport.request(WS_METHODS.gitStatus, input),
165165 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
166 suggestCommitAndBranch: (input) =>
167 transport.request(WS_METHODS.gitSuggestCommitAndBranch, input),
166168 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
167169 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
168170 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
packages/contracts/src/git.ts+12
......@@ -56,6 +56,12 @@ export const GitRunStackedActionInput = Schema.Struct({
5656});
5757export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
5858
59export const GitSuggestCommitAndBranchInput = Schema.Struct({
60 cwd: TrimmedNonEmptyStringSchema,
61 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
62});
63export type GitSuggestCommitAndBranchInput = typeof GitSuggestCommitAndBranchInput.Type;
64
5965export const GitListBranchesInput = Schema.Struct({
6066 cwd: TrimmedNonEmptyStringSchema,
6167});
......@@ -161,6 +167,12 @@ export const GitRunStackedActionResult = Schema.Struct({
161167});
162168export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
163169
170export const GitSuggestCommitAndBranchResult = Schema.Struct({
171 commitMessage: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000)),
172 branch: TrimmedNonEmptyStringSchema,
173});
174export type GitSuggestCommitAndBranchResult = typeof GitSuggestCommitAndBranchResult.Type;
175
164176export const GitPullResult = Schema.Struct({
165177 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
166178 branch: TrimmedNonEmptyStringSchema,
packages/contracts/src/ipc.ts+5
......@@ -11,6 +11,8 @@ import type {
1111 GitRemoveWorktreeInput,
1212 GitRunStackedActionInput,
1313 GitRunStackedActionResult,
14 GitSuggestCommitAndBranchInput,
15 GitSuggestCommitAndBranchResult,
1416 GitStatusInput,
1517 GitStatusResult,
1618} from "./git";
......@@ -88,6 +90,9 @@ export interface NativeApi {
8890 pull: (input: GitPullInput) => Promise<GitPullResult>;
8991 status: (input: GitStatusInput) => Promise<GitStatusResult>;
9092 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
93 suggestCommitAndBranch: (
94 input: GitSuggestCommitAndBranchInput,
95 ) => Promise<GitSuggestCommitAndBranchResult>;
9196 };
9297 contextMenu: {
9398 show: <T extends string>(
packages/contracts/src/ws.ts+3
......@@ -18,6 +18,7 @@ import {
1818 GitPullInput,
1919 GitRemoveWorktreeInput,
2020 GitRunStackedActionInput,
21 GitSuggestCommitAndBranchInput,
2122 GitStatusInput,
2223} from "./git";
2324import {
......@@ -48,6 +49,7 @@ export const WS_METHODS = {
4849 gitPull: "git.pull",
4950 gitStatus: "git.status",
5051 gitRunStackedAction: "git.runStackedAction",
52 gitSuggestCommitAndBranch: "git.suggestCommitAndBranch",
5153 gitListBranches: "git.listBranches",
5254 gitCreateWorktree: "git.createWorktree",
5355 gitRemoveWorktree: "git.removeWorktree",
......@@ -109,6 +111,7 @@ export const WebSocketRequestBody = Schema.Union([
109111 tagRequestBody(WS_METHODS.gitPull, GitPullInput),
110112 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),
111113 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
114 tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput),
112115 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
113116 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
114117 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),