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) => {...@@ -196,6 +196,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
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",
199 }),200 }),
200 },201 },
201 Effect.gen(function* () {202 Effect.gen(function* () {
...@@ -211,6 +212,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -211,6 +212,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
211 expect(generated.subject.length).toBeLessThanOrEqual(72);212 expect(generated.subject.length).toBeLessThanOrEqual(72);
212 expect(generated.subject.endsWith(".")).toBe(false);213 expect(generated.subject.endsWith(".")).toBe(false);
213 expect(generated.body).toBe("- added migration\n- updated tests");214 expect(generated.body).toBe("- added migration\n- updated tests");
215 expect(generated.branch).toBe("feature/fix/important-system-change");
214 }),216 }),
215 ),217 ),
216 );218 );
apps/server/src/git/Layers/CodexTextGeneration.ts+12-1
...@@ -111,6 +111,14 @@ function sanitizeBranchName(raw: string): string {...@@ -111,6 +111,14 @@ function sanitizeBranchName(raw: string): string {
111 return branchFragment.length > 0 ? branchFragment : "update";111 return branchFragment.length > 0 ? branchFragment : "update";
112}112}
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
114const makeCodexTextGeneration = Effect.gen(function* () {122const makeCodexTextGeneration = Effect.gen(function* () {
115 const fileSystem = yield* FileSystem.FileSystem;123 const fileSystem = yield* FileSystem.FileSystem;
116 const path = yield* Path.Path;124 const path = yield* Path.Path;
...@@ -330,10 +338,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -330,10 +338,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
330 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {338 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
331 const prompt = [339 const prompt = [
332 "You write concise git commit messages.",340 "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.",
334 "Rules:",342 "Rules:",
335 "- subject must be imperative, <= 72 chars, and no trailing period",343 "- subject must be imperative, <= 72 chars, and no trailing period",
336 "- body can be empty string or short bullet points",344 "- body can be empty string or short bullet points",
345 "- branch must be a short semantic git branch fragment for this change",
337 "- capture the primary user-visible or developer-visible change",346 "- capture the primary user-visible or developer-visible change",
338 "",347 "",
339 `Branch: ${input.branch ?? "(detached)"}`,348 `Branch: ${input.branch ?? "(detached)"}`,
...@@ -352,6 +361,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -352,6 +361,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
352 outputSchemaJson: Schema.Struct({361 outputSchemaJson: Schema.Struct({
353 subject: Schema.String,362 subject: Schema.String,
354 body: Schema.String,363 body: Schema.String,
364 branch: Schema.String,
355 }),365 }),
356 }).pipe(366 }).pipe(
357 Effect.map(367 Effect.map(
...@@ -359,6 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -359,6 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
359 ({369 ({
360 subject: sanitizeCommitSubject(generated.subject),370 subject: sanitizeCommitSubject(generated.subject),
361 body: generated.body.trim(),371 body: generated.body.trim(),
372 branch: sanitizeFeatureBranchName(generated.branch),
362 }) satisfies CommitMessageGenerationResult,373 }) satisfies CommitMessageGenerationResult,
363 ),374 ),
364 );375 );
apps/server/src/git/Layers/GitManager.test.ts+81-1
...@@ -32,7 +32,7 @@ interface FakeGitTextGeneration {...@@ -32,7 +32,7 @@ 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 }, TextGenerationError>;35 }) => Effect.Effect<{ subject: string; body: string; branch: string }, TextGenerationError>;
36 generatePrContent: (input: {36 generatePrContent: (input: {
37 cwd: string;37 cwd: string;
38 baseBranch: string;38 baseBranch: string;
...@@ -108,6 +108,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T...@@ -108,6 +108,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
108 Effect.succeed({108 Effect.succeed({
109 subject: "Implement stacked git actions",109 subject: "Implement stacked git actions",
110 body: "",110 body: "",
111 branch: "feature/implement-stacked-git-actions",
111 }),112 }),
112 generatePrContent: () =>113 generatePrContent: () =>
113 Effect.succeed({114 Effect.succeed({
...@@ -288,6 +289,16 @@ function runStackedAction(...@@ -288,6 +289,16 @@ function runStackedAction(
288 return manager.runStackedAction(input);289 return manager.runStackedAction(input);
289}290}
290291
292function suggestCommitAndBranch(
293 manager: GitManagerShape,
294 input: {
295 cwd: string;
296 commitMessage?: string;
297 },
298) {
299 return manager.suggestCommitAndBranch(input);
300}
301
291function makeManager(input?: {302function makeManager(input?: {
292 ghScenario?: FakeGhScenario;303 ghScenario?: FakeGhScenario;
293 textGeneration?: Partial<FakeGitTextGeneration>;304 textGeneration?: Partial<FakeGitTextGeneration>;
...@@ -501,6 +512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -501,6 +512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
501 return {512 return {
502 subject: "this should not be used",513 subject: "this should not be used",
503 body: "",514 body: "",
515 branch: "feature/unused",
504 };516 };
505 }),517 }),
506 },518 },
...@@ -527,6 +539,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -527,6 +539,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
527 }),539 }),
528 );540 );
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
530 it.effect("skips commit when there are no uncommitted changes", () =>593 it.effect("skips commit when there are no uncommitted changes", () =>
531 Effect.gen(function* () {594 Effect.gen(function* () {
532 const repoDir = yield* makeTempDir("t3code-git-manager-");595 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -544,6 +607,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -544,6 +607,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
544 }),607 }),
545 );608 );
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
547 it.effect("commits and pushes with upstream auto-setup when needed", () =>627 it.effect("commits and pushes with upstream auto-setup when needed", () =>
548 Effect.gen(function* () {628 Effect.gen(function* () {
549 const repoDir = yield* makeTempDir("t3code-git-manager-");629 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 {...@@ -85,9 +85,10 @@ 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 }): {88function sanitizeCommitMessage(generated: { subject: string; body: string; branch: string }): {
89 subject: string;89 subject: string;
90 body: string;90 body: string;
91 branch: string;
91} {92} {
92 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";93 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
93 const subject = rawSubject.replace(/[.]+$/g, "").trim();94 const subject = rawSubject.replace(/[.]+$/g, "").trim();
...@@ -95,9 +96,44 @@ function sanitizeCommitMessage(generated: { subject: string; body: string }): {...@@ -95,9 +96,44 @@ function sanitizeCommitMessage(generated: { subject: string; body: string }): {
95 return {96 return {
96 subject: safeSubject,97 subject: safeSubject,
97 body: generated.body.trim(),98 body: generated.body.trim(),
99 branch: generated.branch,
98 };100 };
99}101}
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
101function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null {137function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null {
102 const normalized = raw.replace(/\r\n/g, "\n").trim();138 const normalized = raw.replace(/\r\n/g, "\n").trim();
103 if (normalized.length === 0) {139 if (normalized.length === 0) {
...@@ -253,30 +289,62 @@ export const makeGitManager = Effect.gen(function* () {...@@ -253,30 +289,62 @@ export const makeGitManager = Effect.gen(function* () {
253 return "main";289 return "main";
254 });290 });
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 }) =>
257 Effect.gen(function* () {297 Effect.gen(function* () {
258 const context = yield* gitCore.prepareCommitContext(cwd);298 const context = yield* gitCore.prepareCommitContext(input.cwd);
259 if (!context) {299 if (!context) {
260 return { status: "skipped_no_changes" as const };300 return null;
261 }301 }
262302
263 let generated = parseCustomCommitMessage(commitMessage ?? "");303 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");
264 if (!generated) {304 if (customCommit) {
265 generated = yield* textGeneration305 const branch = sanitizeFeatureBranchName(customCommit.subject);
266 .generateCommitMessage({306 return {
267 cwd,307 subject: customCommit.subject,
268 branch,308 body: customCommit.body,
269 stagedSummary: limitContext(context.stagedSummary, 8_000),309 branch,
270 stagedPatch: limitContext(context.stagedPatch, 50_000),310 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),
271 })311 };
272 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));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 };
273 }341 }
274342
275 const { commitSha } = yield* gitCore.commit(cwd, generated.subject, generated.body);343 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body);
276 return {344 return {
277 status: "created" as const,345 status: "created" as const,
278 commitSha,346 commitSha,
279 subject: generated.subject,347 subject: suggestion.subject,
280 };348 };
281 });349 });
282350
...@@ -381,6 +449,28 @@ export const makeGitManager = Effect.gen(function* () {...@@ -381,6 +449,28 @@ export const makeGitManager = Effect.gen(function* () {
381 };449 };
382 });450 });
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
384 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(474 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(
385 function* (input) {475 function* (input) {
386 const wantsPush = input.action !== "commit";476 const wantsPush = input.action !== "commit";
...@@ -419,6 +509,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -419,6 +509,7 @@ export const makeGitManager = Effect.gen(function* () {
419 return {509 return {
420 status,510 status,
421 runStackedAction,511 runStackedAction,
512 suggestCommitAndBranch,
422 } satisfies GitManagerShape;513 } satisfies GitManagerShape;
423});514});
424515
apps/server/src/git/Services/GitManager.ts+9
...@@ -9,6 +9,8 @@...@@ -9,6 +9,8 @@
9import {9import {
10 GitRunStackedActionInput,10 GitRunStackedActionInput,
11 GitRunStackedActionResult,11 GitRunStackedActionResult,
12 GitSuggestCommitAndBranchInput,
13 GitSuggestCommitAndBranchResult,
12 GitStatusInput,14 GitStatusInput,
13 GitStatusResult,15 GitStatusResult,
14} from "@t3tools/contracts";16} from "@t3tools/contracts";
...@@ -33,6 +35,13 @@ export interface GitManagerShape {...@@ -33,6 +35,13 @@ export interface GitManagerShape {
33 readonly runStackedAction: (35 readonly runStackedAction: (
34 input: GitRunStackedActionInput,36 input: GitRunStackedActionInput,
35 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;37 ) => 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>;
36}45}
3746
38/**47/**
apps/server/src/git/Services/TextGeneration.ts+1
...@@ -22,6 +22,7 @@ export interface CommitMessageGenerationInput {...@@ -22,6 +22,7 @@ export interface CommitMessageGenerationInput {
22export interface CommitMessageGenerationResult {22export interface CommitMessageGenerationResult {
23 subject: string;23 subject: string;
24 body: string;24 body: string;
25 branch: string;
25}26}
2627
27export interface PrContentGenerationInput {28export interface PrContentGenerationInput {
apps/server/src/wsServer.test.ts+31-1
...@@ -1555,7 +1555,8 @@ describe("WebSocket Server", () => {...@@ -1555,7 +1555,8 @@ describe("WebSocket Server", () => {
15551555
1556 const status = vi.fn(() => Effect.succeed(statusResult));1556 const status = vi.fn(() => Effect.succeed(statusResult));
1557 const runStackedAction = vi.fn(() => Effect.void as any);1557 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
1560 server = await createTestServer({ cwd: "/test", gitManager });1561 server = await createTestServer({ cwd: "/test", gitManager });
1561 const addr = server.address();1562 const addr = server.address();
...@@ -1585,6 +1586,7 @@ describe("WebSocket Server", () => {...@@ -1585,6 +1586,7 @@ describe("WebSocket Server", () => {
1585 const gitManager: GitManagerShape = {1586 const gitManager: GitManagerShape = {
1586 status: vi.fn(() => Effect.void as any),1587 status: vi.fn(() => Effect.void as any),
1587 runStackedAction,1588 runStackedAction,
1589 suggestCommitAndBranch: vi.fn(() => Effect.void as any),
1588 };1590 };
15891591
1590 server = await createTestServer({ cwd: "/test", gitManager });1592 server = await createTestServer({ cwd: "/test", gitManager });
...@@ -1607,6 +1609,34 @@ describe("WebSocket Server", () => {...@@ -1607,6 +1609,34 @@ describe("WebSocket Server", () => {
1607 });1609 });
1608 });1610 });
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
1610 it("rejects websocket connections without a valid auth token", async () => {1640 it("rejects websocket connections without a valid auth token", async () => {
1611 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });1641 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
1612 const addr = server.address();1642 const addr = server.address();
apps/server/src/wsServer.ts+5
...@@ -668,6 +668,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -668,6 +668,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
668 return yield* gitManager.runStackedAction(body);668 return yield* gitManager.runStackedAction(body);
669 }669 }
670670
671 case WS_METHODS.gitSuggestCommitAndBranch: {
672 const body = stripRequestTag(request.body);
673 return yield* gitManager.suggestCommitAndBranch(body);
674 }
675
671 case WS_METHODS.gitListBranches: {676 case WS_METHODS.gitListBranches: {
672 const body = stripRequestTag(request.body);677 const body = stripRequestTag(request.body);
673 return yield* git.listBranches(body);678 return yield* git.listBranches(body);
apps/web/src/components/GitActionsControl.logic.test.ts+16-8
...@@ -910,18 +910,26 @@ describe("summarizeGitResult", () => {...@@ -910,18 +910,26 @@ describe("summarizeGitResult", () => {
910});910});
911911
912describe("resolveAutoFeatureBranchName", () => {912describe("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", () => {918 it("normalizes preferred names that already include a branch namespace", () => {
916 const branch = resolveAutoFeatureBranchName(["main", "feature/other"], day);919 const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions");
917 assert.equal(branch, "feature/stacked-20260301");920 assert.equal(branch, "feature/refine-toolbar-actions");
918 });921 });
919922
920 it("increments suffix when the base name already exists", () => {923 it("increments suffix when the preferred branch name already exists", () => {
921 const branch = resolveAutoFeatureBranchName(924 const branch = resolveAutoFeatureBranchName(
922 ["main", "feature/stacked-20260301", "feature/stacked-20260301-2"],925 ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"],
923 day,926 "fix toast copy",
924 );927 );
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");
926 });934 });
927});935});
apps/web/src/components/GitActionsControl.logic.ts+31-13
...@@ -35,7 +35,7 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";...@@ -35,7 +35,7 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";
3535
36const SHORT_SHA_LENGTH = 7;36const SHORT_SHA_LENGTH = 7;
37const TOAST_DESCRIPTION_MAX = 72;37const TOAST_DESCRIPTION_MAX = 72;
38const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked";38const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
3939
40function shortenSha(sha: string | undefined): string | null {40function shortenSha(sha: string | undefined): string | null {
41 if (!sha) return null;41 if (!sha) return null;
...@@ -321,29 +321,47 @@ export function resolveDefaultBranchActionDialogCopy(input: {...@@ -321,29 +321,47 @@ export function resolveDefaultBranchActionDialogCopy(input: {
321 };321 };
322}322}
323323
324function formatDateForBranch(now: Date): string {324function sanitizeBranchFragment(raw: string): string {
325 const year = now.getUTCFullYear();325 const normalized = raw
326 const month = String(now.getUTCMonth() + 1).padStart(2, "0");326 .trim()
327 const day = String(now.getUTCDate()).padStart(2, "0");327 .toLowerCase()
328 return `${year}${month}${day}`;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";
329}340}
330341
331export function resolveAutoFeatureBranchName(342export function resolveAutoFeatureBranchName(
332 existingBranchNames: readonly string[],343 existingBranchNames: readonly string[],
333 now = new Date(),344 preferredBranch?: string,
334): string {345): string {
335 const daySlug = formatDateForBranch(now);346 const preferred = preferredBranch?.trim();
336 const base = `${AUTO_FEATURE_BRANCH_PREFIX}-${daySlug}`;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}`;
337 const existingNames = new Set(existingBranchNames);355 const existingNames = new Set(existingBranchNames);
338356
339 if (!existingNames.has(base)) {357 if (!existingNames.has(resolvedBase)) {
340 return base;358 return resolvedBase;
341 }359 }
342360
343 let suffix = 2;361 let suffix = 2;
344 while (existingNames.has(`${base}-${suffix}`)) {362 while (existingNames.has(`${resolvedBase}-${suffix}`)) {
345 suffix += 1;363 suffix += 1;
346 }364 }
347365
348 return `${base}-${suffix}`;366 return `${resolvedBase}-${suffix}`;
349}367}
apps/web/src/components/GitActionsControl.tsx+42-8
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 gitMutationKeys,39 gitMutationKeys,
40 gitPullMutationOptions,40 gitPullMutationOptions,
41 gitRunStackedActionMutationOptions,41 gitRunStackedActionMutationOptions,
42 gitSuggestCommitAndBranchMutationOptions,
42 gitStatusQueryOptions,43 gitStatusQueryOptions,
43 invalidateGitQueries,44 invalidateGitQueries,
44} from "~/lib/gitReactQuery";45} from "~/lib/gitReactQuery";
...@@ -172,6 +173,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -172,6 +173,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
172 const runImmediateGitActionMutation = useMutation(173 const runImmediateGitActionMutation = useMutation(
173 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),174 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
174 );175 );
176 const suggestCommitAndBranchMutation = useMutation(
177 gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }),
178 );
175 const createBranchAndCheckoutMutation = useMutation(179 const createBranchAndCheckoutMutation = useMutation(
176 gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }),180 gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }),
177 );181 );
...@@ -179,11 +183,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -179,11 +183,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
179183
180 const isRunStackedActionRunning =184 const isRunStackedActionRunning =
181 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;185 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
186 const isSuggestCommitAndBranchRunning =
187 useIsMutating({ mutationKey: gitMutationKeys.suggestCommitAndBranch(gitCwd) }) > 0;
182 const isCreateBranchAndCheckoutRunning =188 const isCreateBranchAndCheckoutRunning =
183 useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0;189 useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0;
184 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;190 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
185 const isGitActionRunning =191 const isGitActionRunning =
186 isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning;192 isRunStackedActionRunning ||
193 isSuggestCommitAndBranchRunning ||
194 isCreateBranchAndCheckoutRunning ||
195 isPullRunning;
187 const isDefaultBranch = useMemo(() => {196 const isDefaultBranch = useMemo(() => {
188 const branchName = gitStatusForActions?.branch;197 const branchName = gitStatusForActions?.branch;
189 if (!branchName) return false;198 if (!branchName) return false;
...@@ -443,20 +452,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -443,20 +452,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
443 forcePushOnlyProgress?: boolean;452 forcePushOnlyProgress?: boolean;
444 onConfirmed?: () => void;453 onConfirmed?: () => void;
445 }) => {454 }) => {
446 const branchName = resolveAutoFeatureBranchName(455 const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? [];
447 branchList?.branches.map((branch) => branch.name) ?? [],456 const providedCommitMessage = actionParams.commitMessage?.trim() || undefined;
448 );457 const shouldSuggestCommitAndBranch =
458 !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges;
449459
450 const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName);
451 const checkoutToastId = toastManager.add({460 const checkoutToastId = toastManager.add({
452 type: "loading",461 type: "loading",
453 title: `Creating ${branchName}...`,462 title: "Preparing feature branch...",
454 timeout: 0,463 timeout: 0,
455 data: threadToastData,464 data: threadToastData,
456 });465 });
457466
458 void checkoutPromise467 const suggestionPromise = shouldSuggestCommitAndBranch
459 .then(() => {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 }) => {
460 const statusOverride = gitStatusForActions492 const statusOverride = gitStatusForActions
461 ? {493 ? {
462 ...gitStatusForActions,494 ...gitStatusForActions,
...@@ -469,6 +501,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -469,6 +501,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
469 : null;501 : null;
470 return runGitActionWithToast({502 return runGitActionWithToast({
471 ...actionParams,503 ...actionParams,
504 ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}),
472 skipDefaultBranchPrompt: true,505 skipDefaultBranchPrompt: true,
473 statusOverride,506 statusOverride,
474 isDefaultBranchOverride: false,507 isDefaultBranchOverride: false,
...@@ -489,6 +522,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -489,6 +522,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
489 createBranchAndCheckoutMutation,522 createBranchAndCheckoutMutation,
490 gitStatusForActions,523 gitStatusForActions,
491 runGitActionWithToast,524 runGitActionWithToast,
525 suggestCommitAndBranchMutation,
492 threadToastData,526 threadToastData,
493 ],527 ],
494 );528 );
apps/web/src/lib/gitReactQuery.test.ts+12
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 gitMutationKeys,4 gitMutationKeys,
5 gitPullMutationOptions,5 gitPullMutationOptions,
6 gitRunStackedActionMutationOptions,6 gitRunStackedActionMutationOptions,
7 gitSuggestCommitAndBranchMutationOptions,
7} from "./gitReactQuery";8} from "./gitReactQuery";
89
9describe("gitMutationKeys", () => {10describe("gitMutationKeys", () => {
...@@ -16,6 +17,12 @@ describe("gitMutationKeys", () => {...@@ -16,6 +17,12 @@ describe("gitMutationKeys", () => {
16 it("scopes pull keys by cwd", () => {17 it("scopes pull keys by cwd", () => {
17 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));18 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
18 });19 });
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 });
19});26});
2027
21describe("git mutation options", () => {28describe("git mutation options", () => {
...@@ -30,4 +37,9 @@ describe("git mutation options", () => {...@@ -30,4 +37,9 @@ describe("git mutation options", () => {
30 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });37 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
31 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));38 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
32 });39 });
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 });
33});45});
apps/web/src/lib/gitReactQuery.ts+16
...@@ -19,6 +19,8 @@ export const gitMutationKeys = {...@@ -19,6 +19,8 @@ export const gitMutationKeys = {
19 createBranchAndCheckout: (cwd: string | null) =>19 createBranchAndCheckout: (cwd: string | null) =>
20 ["git", "mutation", "create-branch-and-checkout", cwd] as const,20 ["git", "mutation", "create-branch-and-checkout", cwd] as const,
21 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,21 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,
22 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,24 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
23};25};
2426
...@@ -134,6 +136,20 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -134,6 +136,20 @@ export function gitRunStackedActionMutationOptions(input: {
134 });136 });
135}137}
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
137export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {153export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
138 return mutationOptions({154 return mutationOptions({
139 mutationKey: gitMutationKeys.pull(input.cwd),155 mutationKey: gitMutationKeys.pull(input.cwd),
apps/web/src/wsNativeApi.test.ts+1-1
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ProjectId,6 ProjectId,
7 ThreadId,7 ThreadId,
8 WS_CHANNELS,8 WS_CHANNELS,
9 WS_METHODS,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";11import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1112
...@@ -376,5 +377,4 @@ describe("wsNativeApi", () => {...@@ -376,5 +377,4 @@ describe("wsNativeApi", () => {
376 { x: 20, y: 30 },377 { x: 20, y: 30 },
377 );378 );
378 });379 });
379
380});380});
apps/web/src/wsNativeApi.ts+2
...@@ -163,6 +163,8 @@ export function createWsNativeApi(): NativeApi {...@@ -163,6 +163,8 @@ export function createWsNativeApi(): NativeApi {
163 pull: (input) => transport.request(WS_METHODS.gitPull, input),163 pull: (input) => transport.request(WS_METHODS.gitPull, input),
164 status: (input) => transport.request(WS_METHODS.gitStatus, input),164 status: (input) => transport.request(WS_METHODS.gitStatus, input),
165 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),165 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
166 suggestCommitAndBranch: (input) =>
167 transport.request(WS_METHODS.gitSuggestCommitAndBranch, input),
166 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),168 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
167 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),169 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
168 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),170 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
packages/contracts/src/git.ts+12
...@@ -56,6 +56,12 @@ export const GitRunStackedActionInput = Schema.Struct({...@@ -56,6 +56,12 @@ export const GitRunStackedActionInput = Schema.Struct({
56});56});
57export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;57export 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
59export const GitListBranchesInput = Schema.Struct({65export const GitListBranchesInput = Schema.Struct({
60 cwd: TrimmedNonEmptyStringSchema,66 cwd: TrimmedNonEmptyStringSchema,
61});67});
...@@ -161,6 +167,12 @@ export const GitRunStackedActionResult = Schema.Struct({...@@ -161,6 +167,12 @@ export const GitRunStackedActionResult = Schema.Struct({
161});167});
162export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;168export 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
164export const GitPullResult = Schema.Struct({176export const GitPullResult = Schema.Struct({
165 status: Schema.Literals(["pulled", "skipped_up_to_date"]),177 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
166 branch: TrimmedNonEmptyStringSchema,178 branch: TrimmedNonEmptyStringSchema,
packages/contracts/src/ipc.ts+5
...@@ -11,6 +11,8 @@ import type {...@@ -11,6 +11,8 @@ import type {
11 GitRemoveWorktreeInput,11 GitRemoveWorktreeInput,
12 GitRunStackedActionInput,12 GitRunStackedActionInput,
13 GitRunStackedActionResult,13 GitRunStackedActionResult,
14 GitSuggestCommitAndBranchInput,
15 GitSuggestCommitAndBranchResult,
14 GitStatusInput,16 GitStatusInput,
15 GitStatusResult,17 GitStatusResult,
16} from "./git";18} from "./git";
...@@ -88,6 +90,9 @@ export interface NativeApi {...@@ -88,6 +90,9 @@ export interface NativeApi {
88 pull: (input: GitPullInput) => Promise<GitPullResult>;90 pull: (input: GitPullInput) => Promise<GitPullResult>;
89 status: (input: GitStatusInput) => Promise<GitStatusResult>;91 status: (input: GitStatusInput) => Promise<GitStatusResult>;
90 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;92 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
93 suggestCommitAndBranch: (
94 input: GitSuggestCommitAndBranchInput,
95 ) => Promise<GitSuggestCommitAndBranchResult>;
91 };96 };
92 contextMenu: {97 contextMenu: {
93 show: <T extends string>(98 show: <T extends string>(
packages/contracts/src/ws.ts+3
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 GitPullInput,18 GitPullInput,
19 GitRemoveWorktreeInput,19 GitRemoveWorktreeInput,
20 GitRunStackedActionInput,20 GitRunStackedActionInput,
21 GitSuggestCommitAndBranchInput,
21 GitStatusInput,22 GitStatusInput,
22} from "./git";23} from "./git";
23import {24import {
...@@ -48,6 +49,7 @@ export const WS_METHODS = {...@@ -48,6 +49,7 @@ export const WS_METHODS = {
48 gitPull: "git.pull",49 gitPull: "git.pull",
49 gitStatus: "git.status",50 gitStatus: "git.status",
50 gitRunStackedAction: "git.runStackedAction",51 gitRunStackedAction: "git.runStackedAction",
52 gitSuggestCommitAndBranch: "git.suggestCommitAndBranch",
51 gitListBranches: "git.listBranches",53 gitListBranches: "git.listBranches",
52 gitCreateWorktree: "git.createWorktree",54 gitCreateWorktree: "git.createWorktree",
53 gitRemoveWorktree: "git.removeWorktree",55 gitRemoveWorktree: "git.removeWorktree",
...@@ -109,6 +111,7 @@ export const WebSocketRequestBody = Schema.Union([...@@ -109,6 +111,7 @@ export const WebSocketRequestBody = Schema.Union([
109 tagRequestBody(WS_METHODS.gitPull, GitPullInput),111 tagRequestBody(WS_METHODS.gitPull, GitPullInput),
110 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),112 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),
111 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),113 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
114 tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput),
112 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),115 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
113 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),116 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
114 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),117 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),