authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 13:10:12-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 20:46:05-08:00
log906550f9af7d6fc37e1c9e4f21018dcb9ea03591
tree5e1f4fccf8c614dc8153893d36b7bcfc4ed416a3
parentc2aaa89b57ae07095bc337cb5d0a7837eee31f99

Merge feature-branch checkout into atomic runStackedAction

Consolidate the fragile 3-RPC flow (suggestCommitAndBranch → createBranch + checkout → runStackedAction) into a single server-side operation triggered by `runStackedAction({ featureBranch: true })`. The server now generates the branch name via AI, deduplicates it, creates + checks out the branch, then runs the normal commit/push/PR pipeline atomically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

19 files changed, 182 insertions(+), 301 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+1
...@@ -110,6 +110,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>...@@ -110,6 +110,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
110 createBranch: (input) => core.createBranch(input),110 createBranch: (input) => core.createBranch(input),
111 checkoutBranch: (input) => core.checkoutBranch(input),111 checkoutBranch: (input) => core.checkoutBranch(input),
112 initRepo: (input) => core.initRepo(input),112 initRepo: (input) => core.initRepo(input),
113 listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd),
113 } satisfies GitCoreShape;114 } satisfies GitCoreShape;
114 });115 });
115116
apps/server/src/git/Layers/GitCore.ts+15
...@@ -1172,6 +1172,20 @@ const makeGitCore = Effect.gen(function* () {...@@ -1172,6 +1172,20 @@ const makeGitCore = Effect.gen(function* () {
1172 fallbackErrorMessage: "git init failed",1172 fallbackErrorMessage: "git init failed",
1173 }).pipe(Effect.asVoid);1173 }).pipe(Effect.asVoid);
11741174
1175 const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) =>
1176 runGitStdout("GitCore.listLocalBranchNames", cwd, [
1177 "branch",
1178 "--list",
1179 "--format=%(refname:short)",
1180 ]).pipe(
1181 Effect.map((stdout) =>
1182 stdout
1183 .split("\n")
1184 .map((line) => line.trim())
1185 .filter((line) => line.length > 0),
1186 ),
1187 );
1188
1175 return {1189 return {
1176 status,1190 status,
1177 statusDetails,1191 statusDetails,
...@@ -1188,6 +1202,7 @@ const makeGitCore = Effect.gen(function* () {...@@ -1188,6 +1202,7 @@ const makeGitCore = Effect.gen(function* () {
1188 createBranch,1202 createBranch,
1189 checkoutBranch,1203 checkoutBranch,
1190 initRepo,1204 initRepo,
1205 listLocalBranchNames,
1191 } satisfies GitCoreShape;1206 } satisfies GitCoreShape;
1192});1207});
11931208
apps/server/src/git/Layers/GitManager.test.ts+39-26
...@@ -290,21 +290,12 @@ function runStackedAction(...@@ -290,21 +290,12 @@ function runStackedAction(
290 cwd: string;290 cwd: string;
291 action: "commit" | "commit_push" | "commit_push_pr";291 action: "commit" | "commit_push" | "commit_push_pr";
292 commitMessage?: string;292 commitMessage?: string;
293 featureBranch?: boolean;
293 },294 },
294) {295) {
295 return manager.runStackedAction(input);296 return manager.runStackedAction(input);
296}297}
297298
298function suggestCommitAndBranch(
299 manager: GitManagerShape,
300 input: {
301 cwd: string;
302 commitMessage?: string;
303 },
304) {
305 return manager.suggestCommitAndBranch(input);
306}
307
308function makeManager(input?: {299function makeManager(input?: {
309 ghScenario?: FakeGhScenario;300 ghScenario?: FakeGhScenario;
310 textGeneration?: Partial<FakeGitTextGeneration>;301 textGeneration?: Partial<FakeGitTextGeneration>;
...@@ -492,6 +483,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -492,6 +483,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
492 action: "commit",483 action: "commit",
493 });484 });
494485
486 expect(result.branch.status).toBe("skipped_not_requested");
495 expect(result.commit.status).toBe("created");487 expect(result.commit.status).toBe("created");
496 expect(result.push.status).toBe("skipped_not_requested");488 expect(result.push.status).toBe("skipped_not_requested");
497 expect(result.pr.status).toBe("skipped_not_requested");489 expect(result.pr.status).toBe("skipped_not_requested");
...@@ -529,6 +521,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -529,6 +521,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
529 commitMessage: "feat: custom summary line\n\n- details from user",521 commitMessage: "feat: custom summary line\n\n- details from user",
530 });522 });
531523
524 expect(result.branch.status).toBe("skipped_not_requested");
532 expect(result.commit.status).toBe("created");525 expect(result.commit.status).toBe("created");
533 expect(result.commit.subject).toBe("feat: custom summary line");526 expect(result.commit.subject).toBe("feat: custom summary line");
534 expect(generatedCount).toBe(0);527 expect(generatedCount).toBe(0);
...@@ -545,29 +538,39 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -545,29 +538,39 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
545 }),538 }),
546 );539 );
547540
548 it.effect("suggests commit message and semantic branch for dirty worktree", () =>541 it.effect("creates feature branch, commits, and pushes with featureBranch option", () =>
549 Effect.gen(function* () {542 Effect.gen(function* () {
550 const repoDir = yield* makeTempDir("t3code-git-manager-");543 const repoDir = yield* makeTempDir("t3code-git-manager-");
551 yield* initRepo(repoDir);544 yield* initRepo(repoDir);
552 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nsuggest\n");545 const remoteDir = yield* createBareRemote();
546 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
547 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
548 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n");
553549
554 const { manager } = yield* makeManager();550 const { manager } = yield* makeManager();
555 const suggestion = yield* suggestCommitAndBranch(manager, {551 const result = yield* runStackedAction(manager, {
556 cwd: repoDir,552 cwd: repoDir,
553 action: "commit_push",
554 featureBranch: true,
557 });555 });
558556
559 expect(suggestion).toEqual({557 expect(result.branch.status).toBe("created");
560 commitMessage: "Implement stacked git actions",558 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");
561 branch: "feature/implement-stacked-git-actions",559 expect(result.commit.status).toBe("created");
562 });560 expect(result.push.status).toBe("pushed");
561 expect(
562 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe(
563 Effect.map((result) => result.stdout.trim()),
564 ),
565 ).toBe("feature/implement-stacked-git-actions");
563 }),566 }),
564 );567 );
565568
566 it.effect("suggests branch from custom commit message without AI generation", () =>569 it.effect("featureBranch uses custom commit message and derives branch name", () =>
567 Effect.gen(function* () {570 Effect.gen(function* () {
568 const repoDir = yield* makeTempDir("t3code-git-manager-");571 const repoDir = yield* makeTempDir("t3code-git-manager-");
569 yield* initRepo(repoDir);572 yield* initRepo(repoDir);
570 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-suggest\n");573 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n");
571 let generatedCount = 0;574 let generatedCount = 0;
572575
573 const { manager } = yield* makeManager({576 const { manager } = yield* makeManager({
...@@ -583,15 +586,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -583,15 +586,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
583 }),586 }),
584 },587 },
585 });588 });
586 const suggestion = yield* suggestCommitAndBranch(manager, {589 const result = yield* runStackedAction(manager, {
587 cwd: repoDir,590 cwd: repoDir,
591 action: "commit",
592 featureBranch: true,
588 commitMessage: "feat: custom summary line\n\n- details from user",593 commitMessage: "feat: custom summary line\n\n- details from user",
589 });594 });
590595
591 expect(suggestion).toEqual({596 expect(result.branch.status).toBe("created");
592 commitMessage: "feat: custom summary line\n\n- details from user",597 expect(result.branch.name).toBe("feature/feat-custom-summary-line");
593 branch: "feature/feat-custom-summary-line",598 expect(result.commit.status).toBe("created");
594 });599 expect(result.commit.subject).toBe("feat: custom summary line");
595 expect(generatedCount).toBe(0);600 expect(generatedCount).toBe(0);
596 }),601 }),
597 );602 );
...@@ -607,20 +612,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -607,20 +612,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
607 action: "commit",612 action: "commit",
608 });613 });
609614
615 expect(result.branch.status).toBe("skipped_not_requested");
610 expect(result.commit.status).toBe("skipped_no_changes");616 expect(result.commit.status).toBe("skipped_no_changes");
611 expect(result.push.status).toBe("skipped_not_requested");617 expect(result.push.status).toBe("skipped_not_requested");
612 expect(result.pr.status).toBe("skipped_not_requested");618 expect(result.pr.status).toBe("skipped_not_requested");
613 }),619 }),
614 );620 );
615621
616 it.effect("returns a clear error when suggesting from clean worktree", () =>622 it.effect("featureBranch returns error when worktree is clean", () =>
617 Effect.gen(function* () {623 Effect.gen(function* () {
618 const repoDir = yield* makeTempDir("t3code-git-manager-");624 const repoDir = yield* makeTempDir("t3code-git-manager-");
619 yield* initRepo(repoDir);625 yield* initRepo(repoDir);
620626
621 const { manager } = yield* makeManager();627 const { manager } = yield* makeManager();
622 const errorMessage = yield* suggestCommitAndBranch(manager, {628 const errorMessage = yield* runStackedAction(manager, {
623 cwd: repoDir,629 cwd: repoDir,
630 action: "commit",
631 featureBranch: true,
624 }).pipe(632 }).pipe(
625 Effect.flip,633 Effect.flip,
626 Effect.map((error) => error.message),634 Effect.map((error) => error.message),
...@@ -645,6 +653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -645,6 +653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
645 action: "commit_push",653 action: "commit_push",
646 });654 });
647655
656 expect(result.branch.status).toBe("skipped_not_requested");
648 expect(result.commit.status).toBe("created");657 expect(result.commit.status).toBe("created");
649 expect(result.push.status).toBe("pushed");658 expect(result.push.status).toBe("pushed");
650 expect(result.push.setUpstream).toBe(true);659 expect(result.push.setUpstream).toBe(true);
...@@ -689,6 +698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -689,6 +698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
689 action: "commit_push_pr",698 action: "commit_push_pr",
690 });699 });
691700
701 expect(result.branch.status).toBe("skipped_not_requested");
692 expect(result.commit.status).toBe("created");702 expect(result.commit.status).toBe("created");
693 expect(result.push.status).toBe("pushed");703 expect(result.push.status).toBe("pushed");
694 expect(result.push.setUpstream).toBe(true);704 expect(result.push.setUpstream).toBe(true);
...@@ -721,6 +731,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -721,6 +731,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
721 action: "commit_push",731 action: "commit_push",
722 });732 });
723733
734 expect(result.branch.status).toBe("skipped_not_requested");
724 expect(result.commit.status).toBe("skipped_no_changes");735 expect(result.commit.status).toBe("skipped_no_changes");
725 expect(result.push.status).toBe("skipped_up_to_date");736 expect(result.push.status).toBe("skipped_up_to_date");
726 }),737 }),
...@@ -755,6 +766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -755,6 +766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
755 action: "commit_push_pr",766 action: "commit_push_pr",
756 });767 });
757768
769 expect(result.branch.status).toBe("skipped_not_requested");
758 expect(result.pr.status).toBe("opened_existing");770 expect(result.pr.status).toBe("opened_existing");
759 expect(result.pr.number).toBe(42);771 expect(result.pr.number).toBe(42);
760 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);772 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
...@@ -795,6 +807,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -795,6 +807,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
795 action: "commit_push_pr",807 action: "commit_push_pr",
796 });808 });
797809
810 expect(result.branch.status).toBe("skipped_not_requested");
798 expect(result.pr.status).toBe("created");811 expect(result.pr.status).toBe("created");
799 expect(result.pr.number).toBe(88);812 expect(result.pr.number).toBe(88);
800 expect(813 expect(
apps/server/src/git/Layers/GitManager.ts+41-19
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
22
3import { Effect, FileSystem, Layer, Path } from "effect";3import { Effect, FileSystem, Layer, Path } from "effect";
4import { sanitizeFeatureBranchName } from "@t3tools/shared/git";4import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
55
6import { GitManagerError } from "../Errors.ts";6import { GitManagerError } from "../Errors.ts";
7import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";7import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
...@@ -431,28 +431,33 @@ export const makeGitManager = Effect.gen(function* () {...@@ -431,28 +431,33 @@ export const makeGitManager = Effect.gen(function* () {
431 };431 };
432 });432 });
433433
434 const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced(434 const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) =>
435 function* (input) {435 Effect.gen(function* () {
436 const details = yield* gitCore.statusDetails(input.cwd);
437 const suggestion = yield* resolveCommitAndBranchSuggestion({436 const suggestion = yield* resolveCommitAndBranchSuggestion({
438 cwd: input.cwd,437 cwd,
439 branch: details.branch,438 branch,
440 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),439 ...(commitMessage ? { commitMessage } : {}),
441 includeBranch: true,440 includeBranch: true,
442 });441 });
443 if (!suggestion) {442 if (!suggestion) {
444 return yield* gitManagerError(443 return yield* gitManagerError(
445 "suggestCommitAndBranch",444 "runFeatureBranchStep",
446 "Cannot suggest a commit and branch because there are no changes to commit.",445 "Cannot create a feature branch because there are no changes to commit.",
447 );446 );
448 }447 }
449448
449 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
450 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
451 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
452
453 yield* gitCore.createBranch({ cwd, branch: resolvedBranch });
454 yield* gitCore.checkoutBranch({ cwd, branch: resolvedBranch });
455
450 return {456 return {
451 commitMessage: suggestion.commitMessage,457 branchStep: { status: "created" as const, name: resolvedBranch },
452 branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject),458 resolvedCommitMessage: suggestion.commitMessage,
453 };459 };
454 },460 });
455 );
456461
457 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(462 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(
458 function* (input) {463 function* (input) {
...@@ -460,28 +465,46 @@ export const makeGitManager = Effect.gen(function* () {...@@ -460,28 +465,46 @@ export const makeGitManager = Effect.gen(function* () {
460 const wantsPr = input.action === "commit_push_pr";465 const wantsPr = input.action === "commit_push_pr";
461466
462 const initialStatus = yield* gitCore.statusDetails(input.cwd);467 const initialStatus = yield* gitCore.statusDetails(input.cwd);
463 if (wantsPush && !initialStatus.branch) {468 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
464 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");469 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
465 }470 }
466 if (wantsPr && !initialStatus.branch) {471 if (!input.featureBranch && wantsPr && !initialStatus.branch) {
467 return yield* gitManagerError(472 return yield* gitManagerError(
468 "runStackedAction",473 "runStackedAction",
469 "Cannot create a pull request from detached HEAD.",474 "Cannot create a pull request from detached HEAD.",
470 );475 );
471 }476 }
472477
473 const commit = yield* runCommitStep(input.cwd, initialStatus.branch, input.commitMessage);478 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };
479 let commitMessageForStep = input.commitMessage;
480
481 if (input.featureBranch) {
482 const result = yield* runFeatureBranchStep(
483 input.cwd,
484 initialStatus.branch,
485 input.commitMessage,
486 );
487 branchStep = result.branchStep;
488 commitMessageForStep = result.resolvedCommitMessage;
489 } else {
490 branchStep = { status: "skipped_not_requested" as const };
491 }
492
493 const currentBranch = branchStep.name ?? initialStatus.branch;
494
495 const commit = yield* runCommitStep(input.cwd, currentBranch, commitMessageForStep);
474496
475 const push = wantsPush497 const push = wantsPush
476 ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)498 ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch)
477 : { status: "skipped_not_requested" as const };499 : { status: "skipped_not_requested" as const };
478500
479 const pr = wantsPr501 const pr = wantsPr
480 ? yield* runPrStep(input.cwd, initialStatus.branch)502 ? yield* runPrStep(input.cwd, currentBranch)
481 : { status: "skipped_not_requested" as const };503 : { status: "skipped_not_requested" as const };
482504
483 return {505 return {
484 action: input.action,506 action: input.action,
507 branch: branchStep,
485 commit,508 commit,
486 push,509 push,
487 pr,510 pr,
...@@ -492,7 +515,6 @@ export const makeGitManager = Effect.gen(function* () {...@@ -492,7 +515,6 @@ export const makeGitManager = Effect.gen(function* () {
492 return {515 return {
493 status,516 status,
494 runStackedAction,517 runStackedAction,
495 suggestCommitAndBranch,
496 } satisfies GitManagerShape;518 } satisfies GitManagerShape;
497});519});
498520
apps/server/src/git/Services/GitCore.ts+5
...@@ -157,6 +157,11 @@ export interface GitCoreShape {...@@ -157,6 +157,11 @@ export interface GitCoreShape {
157 * Initialize a repository in the provided directory.157 * Initialize a repository in the provided directory.
158 */158 */
159 readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>;159 readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>;
160
161 /**
162 * List local branch names (short format).
163 */
164 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
160}165}
161166
162/**167/**
apps/server/src/git/Services/GitManager.ts+1-9
...@@ -9,8 +9,6 @@...@@ -9,8 +9,6 @@
9import {9import {
10 GitRunStackedActionInput,10 GitRunStackedActionInput,
11 GitRunStackedActionResult,11 GitRunStackedActionResult,
12 GitSuggestCommitAndBranchInput,
13 GitSuggestCommitAndBranchResult,
14 GitStatusInput,12 GitStatusInput,
15 GitStatusResult,13 GitStatusResult,
16} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -31,17 +29,11 @@ export interface GitManagerShape {...@@ -31,17 +29,11 @@ export interface GitManagerShape {
3129
32 /**30 /**
33 * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`).31 * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`).
32 * When `featureBranch` is set, creates and checks out a feature branch first.
34 */33 */
35 readonly runStackedAction: (34 readonly runStackedAction: (
36 input: GitRunStackedActionInput,35 input: GitRunStackedActionInput,
37 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;36 ) => 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>;
45}37}
4638
47/**39/**
apps/server/src/wsServer.test.ts+1-31
...@@ -1555,8 +1555,7 @@ describe("WebSocket Server", () => {...@@ -1555,8 +1555,7 @@ 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 suggestCommitAndBranch = vi.fn(() => Effect.void as any);1558 const gitManager: GitManagerShape = { status, runStackedAction };
1559 const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch };
15601559
1561 server = await createTestServer({ cwd: "/test", gitManager });1560 server = await createTestServer({ cwd: "/test", gitManager });
1562 const addr = server.address();1561 const addr = server.address();
...@@ -1586,7 +1585,6 @@ describe("WebSocket Server", () => {...@@ -1586,7 +1585,6 @@ describe("WebSocket Server", () => {
1586 const gitManager: GitManagerShape = {1585 const gitManager: GitManagerShape = {
1587 status: vi.fn(() => Effect.void as any),1586 status: vi.fn(() => Effect.void as any),
1588 runStackedAction,1587 runStackedAction,
1589 suggestCommitAndBranch: vi.fn(() => Effect.void as any),
1590 };1588 };
15911589
1592 server = await createTestServer({ cwd: "/test", gitManager });1590 server = await createTestServer({ cwd: "/test", gitManager });
...@@ -1609,34 +1607,6 @@ describe("WebSocket Server", () => {...@@ -1609,34 +1607,6 @@ describe("WebSocket Server", () => {
1609 });1607 });
1610 });1608 });
16111609
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
1640 it("rejects websocket connections without a valid auth token", async () => {1610 it("rejects websocket connections without a valid auth token", async () => {
1641 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });1611 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
1642 const addr = server.address();1612 const addr = server.address();
apps/server/src/wsServer.ts-5
...@@ -668,11 +668,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -668,11 +668,6 @@ 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
676 case WS_METHODS.gitListBranches: {671 case WS_METHODS.gitListBranches: {
677 const body = stripRequestTag(request.body);672 const body = stripRequestTag(request.body);
678 return yield* git.listBranches(body);673 return yield* git.listBranches(body);
apps/web/src/components/GitActionsControl.logic.test.ts+4
...@@ -823,6 +823,7 @@ describe("summarizeGitResult", () => {...@@ -823,6 +823,7 @@ describe("summarizeGitResult", () => {
823 it("returns commit-focused toast for commit action", () => {823 it("returns commit-focused toast for commit action", () => {
824 const result = summarizeGitResult({824 const result = summarizeGitResult({
825 action: "commit",825 action: "commit",
826 branch: { status: "skipped_not_requested" },
826 commit: {827 commit: {
827 status: "created",828 status: "created",
828 commitSha: "0123456789abcdef",829 commitSha: "0123456789abcdef",
...@@ -841,6 +842,7 @@ describe("summarizeGitResult", () => {...@@ -841,6 +842,7 @@ describe("summarizeGitResult", () => {
841 it("returns push-focused toast for push action", () => {842 it("returns push-focused toast for push action", () => {
842 const result = summarizeGitResult({843 const result = summarizeGitResult({
843 action: "commit_push",844 action: "commit_push",
845 branch: { status: "skipped_not_requested" },
844 commit: {846 commit: {
845 status: "created",847 status: "created",
846 commitSha: "abcdef0123456789",848 commitSha: "abcdef0123456789",
...@@ -863,6 +865,7 @@ describe("summarizeGitResult", () => {...@@ -863,6 +865,7 @@ describe("summarizeGitResult", () => {
863 it("returns PR-focused toast for created PR action", () => {865 it("returns PR-focused toast for created PR action", () => {
864 const result = summarizeGitResult({866 const result = summarizeGitResult({
865 action: "commit_push_pr",867 action: "commit_push_pr",
868 branch: { status: "skipped_not_requested" },
866 commit: {869 commit: {
867 status: "created",870 status: "created",
868 commitSha: "89abcdef01234567",871 commitSha: "89abcdef01234567",
...@@ -888,6 +891,7 @@ describe("summarizeGitResult", () => {...@@ -888,6 +891,7 @@ describe("summarizeGitResult", () => {
888 it("truncates long description text", () => {891 it("truncates long description text", () => {
889 const result = summarizeGitResult({892 const result = summarizeGitResult({
890 action: "commit_push_pr",893 action: "commit_push_pr",
894 branch: { status: "skipped_not_requested" },
891 commit: {895 commit: {
892 status: "created",896 status: "created",
893 commitSha: "89abcdef01234567",897 commitSha: "89abcdef01234567",
apps/web/src/components/GitActionsControl.logic.ts+7-31
...@@ -3,7 +3,6 @@ import type {...@@ -3,7 +3,6 @@ import type {
3 GitStackedAction,3 GitStackedAction,
4 GitStatusResult,4 GitStatusResult,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { sanitizeBranchFragment } from "@t3tools/shared/git";
76
8export type GitActionIconName = "commit" | "push" | "pr";7export type GitActionIconName = "commit" | "push" | "pr";
98
...@@ -36,7 +35,6 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";...@@ -36,7 +35,6 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";
3635
37const SHORT_SHA_LENGTH = 7;36const SHORT_SHA_LENGTH = 7;
38const TOAST_DESCRIPTION_MAX = 72;37const TOAST_DESCRIPTION_MAX = 72;
39const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
4038
41function shortenSha(sha: string | undefined): string | null {39function shortenSha(sha: string | undefined): string | null {
42 if (!sha) return null;40 if (!sha) return null;
...@@ -59,7 +57,9 @@ export function buildGitActionProgressStages(input: {...@@ -59,7 +57,9 @@ export function buildGitActionProgressStages(input: {
59 hasWorkingTreeChanges: boolean;57 hasWorkingTreeChanges: boolean;
60 forcePushOnly?: boolean;58 forcePushOnly?: boolean;
61 pushTarget?: string;59 pushTarget?: string;
60 featureBranch?: boolean;
62}): string[] {61}): string[] {
62 const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
63 const shouldIncludeCommitStages =63 const shouldIncludeCommitStages =
64 !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges);64 !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges);
65 const commitStages = !shouldIncludeCommitStages65 const commitStages = !shouldIncludeCommitStages
...@@ -69,12 +69,12 @@ export function buildGitActionProgressStages(input: {...@@ -69,12 +69,12 @@ export function buildGitActionProgressStages(input: {
69 : ["Generating commit message...", "Committing..."];69 : ["Generating commit message...", "Committing..."];
70 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";70 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
71 if (input.action === "commit") {71 if (input.action === "commit") {
72 return commitStages;72 return [...branchStages, ...commitStages];
73 }73 }
74 if (input.action === "commit_push") {74 if (input.action === "commit_push") {
75 return [...commitStages, pushStage];75 return [...branchStages, ...commitStages, pushStage];
76 }76 }
77 return [...commitStages, pushStage, "Creating PR..."];77 return [...branchStages, ...commitStages, pushStage, "Creating PR..."];
78}78}
7979
80const withDescription = (title: string, description: string | undefined) =>80const withDescription = (title: string, description: string | undefined) =>
...@@ -322,29 +322,5 @@ export function resolveDefaultBranchActionDialogCopy(input: {...@@ -322,29 +322,5 @@ export function resolveDefaultBranchActionDialogCopy(input: {
322 };322 };
323}323}
324324
325export function resolveAutoFeatureBranchName(325// Re-export from shared for backwards compatibility in this module's exports
326 existingBranchNames: readonly string[],326export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
327 preferredBranch?: string,
328): string {
329 const preferred = preferredBranch?.trim();
330 const normalized = sanitizeBranchFragment(
331 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
332 );
333 const resolvedBase = normalized.includes("/")
334 ? normalized.startsWith("feature/")
335 ? normalized
336 : `feature/${normalized}`
337 : `feature/${normalized}`;
338 const existingNames = new Set(existingBranchNames);
339
340 if (!existingNames.has(resolvedBase)) {
341 return resolvedBase;
342 }
343
344 let suffix = 2;
345 while (existingNames.has(`${resolvedBase}-${suffix}`)) {
346 suffix += 1;
347 }
348
349 return `${resolvedBase}-${suffix}`;
350}
apps/web/src/components/GitActionsControl.tsx+26-129
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts";1import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts";
2import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useEffect, useMemo, useState } from "react";3import { useCallback, useEffect, useMemo, useState } from "react";
4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
...@@ -11,7 +11,6 @@ import {...@@ -11,7 +11,6 @@ import {
11 type GitQuickAction,11 type GitQuickAction,
12 type DefaultBranchConfirmableAction,12 type DefaultBranchConfirmableAction,
13 requiresDefaultBranchConfirmation,13 requiresDefaultBranchConfirmation,
14 resolveAutoFeatureBranchName,
15 resolveDefaultBranchActionDialogCopy,14 resolveDefaultBranchActionDialogCopy,
16 resolveQuickAction,15 resolveQuickAction,
17 summarizeGitResult,16 summarizeGitResult,
...@@ -34,12 +33,10 @@ import { Textarea } from "~/components/ui/textarea";...@@ -34,12 +33,10 @@ import { Textarea } from "~/components/ui/textarea";
34import { toastManager } from "~/components/ui/toast";33import { toastManager } from "~/components/ui/toast";
35import {34import {
36 gitBranchesQueryOptions,35 gitBranchesQueryOptions,
37 gitCreateBranchAndCheckoutMutationOptions,
38 gitInitMutationOptions,36 gitInitMutationOptions,
39 gitMutationKeys,37 gitMutationKeys,
40 gitPullMutationOptions,38 gitPullMutationOptions,
41 gitRunStackedActionMutationOptions,39 gitRunStackedActionMutationOptions,
42 gitSuggestCommitAndBranchMutationOptions,
43 gitStatusQueryOptions,40 gitStatusQueryOptions,
44 invalidateGitQueries,41 invalidateGitQueries,
45} from "~/lib/gitReactQuery";42} from "~/lib/gitReactQuery";
...@@ -60,8 +57,6 @@ interface PendingDefaultBranchAction {...@@ -60,8 +57,6 @@ interface PendingDefaultBranchAction {
60 onConfirmed?: () => void;57 onConfirmed?: () => void;
61}58}
6259
63type GitActionToastId = ReturnType<typeof toastManager.add>;
64
65function getMenuActionDisabledReason(60function getMenuActionDisabledReason(
66 item: GitActionMenuItem,61 item: GitActionMenuItem,
67 gitStatus: GitStatusResult | null,62 gitStatus: GitStatusResult | null,
...@@ -173,26 +168,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -173,26 +168,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
173 const runImmediateGitActionMutation = useMutation(168 const runImmediateGitActionMutation = useMutation(
174 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),169 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
175 );170 );
176 const suggestCommitAndBranchMutation = useMutation(
177 gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }),
178 );
179 const createBranchAndCheckoutMutation = useMutation(
180 gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }),
181 );
182 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));171 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
183172
184 const isRunStackedActionRunning =173 const isRunStackedActionRunning =
185 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;174 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
186 const isSuggestCommitAndBranchRunning =
187 useIsMutating({ mutationKey: gitMutationKeys.suggestCommitAndBranch(gitCwd) }) > 0;
188 const isCreateBranchAndCheckoutRunning =
189 useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0;
190 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;175 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
191 const isGitActionRunning =176 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
192 isRunStackedActionRunning ||
193 isSuggestCommitAndBranchRunning ||
194 isCreateBranchAndCheckoutRunning ||
195 isPullRunning;
196 const isDefaultBranch = useMemo(() => {177 const isDefaultBranch = useMemo(() => {
197 const branchName = gitStatusForActions?.branch;178 const branchName = gitStatusForActions?.branch;
198 if (!branchName) return false;179 if (!branchName) return false;
...@@ -255,26 +236,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -255,26 +236,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
255 forcePushOnlyProgress = false,236 forcePushOnlyProgress = false,
256 onConfirmed,237 onConfirmed,
257 skipDefaultBranchPrompt = false,238 skipDefaultBranchPrompt = false,
258 statusOverride,239 featureBranch = false,
259 isDefaultBranchOverride,
260 progressToastId,
261 }: {240 }: {
262 action: GitStackedAction;241 action: GitStackedAction;
263 commitMessage?: string;242 commitMessage?: string;
264 forcePushOnlyProgress?: boolean;243 forcePushOnlyProgress?: boolean;
265 onConfirmed?: () => void;244 onConfirmed?: () => void;
266 skipDefaultBranchPrompt?: boolean;245 skipDefaultBranchPrompt?: boolean;
267 statusOverride?: GitStatusResult | null;246 featureBranch?: boolean;
268 isDefaultBranchOverride?: boolean;
269 progressToastId?: GitActionToastId;
270 }) => {247 }) => {
271 const actionStatus = statusOverride ?? gitStatusForActions;248 const actionStatus = gitStatusForActions;
272 const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch;249 const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch;
273 const actionBranch = actionStatus?.branch ?? null;250 const actionBranch = actionStatus?.branch ?? null;
274 const includesCommit =251 const includesCommit =
275 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);252 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);
276 if (253 if (
277 !skipDefaultBranchPrompt &&254 !skipDefaultBranchPrompt &&
255 !featureBranch &&
278 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&256 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&
279 actionBranch257 actionBranch
280 ) {258 ) {
...@@ -293,36 +271,26 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -293,36 +271,26 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
293 }271 }
294 onConfirmed?.();272 onConfirmed?.();
295273
296 const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined;274 const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined;
297 const progressStages = buildGitActionProgressStages({275 const progressStages = buildGitActionProgressStages({
298 action,276 action,
299 hasCustomCommitMessage: !!commitMessage?.trim(),277 hasCustomCommitMessage: !!commitMessage?.trim(),
300 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,278 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
301 forcePushOnly: forcePushOnlyProgress,279 forcePushOnly: forcePushOnlyProgress,
280 featureBranch,
302 ...(pushTarget ? { pushTarget } : {}),281 ...(pushTarget ? { pushTarget } : {}),
303 });282 });
304 const resolvedProgressToastId =283 const progressToastId = toastManager.add({
305 progressToastId ??284 type: "loading",
306 toastManager.add({285 title: progressStages[0] ?? "Running git action...",
307 type: "loading",286 timeout: 0,
308 title: progressStages[0] ?? "Running git action...",287 data: threadToastData,
309 timeout: 0,288 });
310 data: threadToastData,
311 });
312
313 if (progressToastId) {
314 toastManager.update(progressToastId, {
315 type: "loading",
316 title: progressStages[0] ?? "Running git action...",
317 timeout: 0,
318 data: threadToastData,
319 });
320 }
321289
322 let stageIndex = 0;290 let stageIndex = 0;
323 const stageInterval = setInterval(() => {291 const stageInterval = setInterval(() => {
324 stageIndex = Math.min(stageIndex + 1, progressStages.length - 1);292 stageIndex = Math.min(stageIndex + 1, progressStages.length - 1);
325 toastManager.update(resolvedProgressToastId, {293 toastManager.update(progressToastId, {
326 title: progressStages[stageIndex] ?? "Running git action...",294 title: progressStages[stageIndex] ?? "Running git action...",
327 type: "loading",295 type: "loading",
328 timeout: 0,296 timeout: 0,
...@@ -337,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -337,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
337 const promise = runImmediateGitActionMutation.mutateAsync({305 const promise = runImmediateGitActionMutation.mutateAsync({
338 action,306 action,
339 ...(commitMessage ? { commitMessage } : {}),307 ...(commitMessage ? { commitMessage } : {}),
308 ...(featureBranch ? { featureBranch } : {}),
340 });309 });
341310
342 try {311 try {
...@@ -357,10 +326,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -357,10 +326,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
357 result.push.status === "pushed" &&326 result.push.status === "pushed" &&
358 !actionIsDefaultBranch;327 !actionIsDefaultBranch;
359 const closeResultToast = () => {328 const closeResultToast = () => {
360 toastManager.close(resolvedProgressToastId);329 toastManager.close(progressToastId);
361 };330 };
362331
363 toastManager.update(resolvedProgressToastId, {332 toastManager.update(progressToastId, {
364 type: "success",333 type: "success",
365 title: resultToast.title,334 title: resultToast.title,
366 description: resultToast.description,335 description: resultToast.description,
...@@ -378,8 +347,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -378,8 +347,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
378 action: "commit_push",347 action: "commit_push",
379 forcePushOnlyProgress: true,348 forcePushOnlyProgress: true,
380 onConfirmed: closeResultToast,349 onConfirmed: closeResultToast,
381 statusOverride: actionStatus,
382 isDefaultBranchOverride: actionIsDefaultBranch,
383 });350 });
384 },351 },
385 },352 },
...@@ -404,8 +371,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -404,8 +371,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
404 closeResultToast();371 closeResultToast();
405 void runGitActionWithToast({372 void runGitActionWithToast({
406 action: "commit_push_pr",373 action: "commit_push_pr",
407 statusOverride: actionStatus,
408 isDefaultBranchOverride: actionIsDefaultBranch,
409 });374 });
410 },375 },
411 },376 },
...@@ -414,7 +379,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -414,7 +379,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
414 });379 });
415 } catch (err) {380 } catch (err) {
416 stopProgressUpdates();381 stopProgressUpdates();
417 toastManager.update(resolvedProgressToastId, {382 toastManager.update(progressToastId, {
418 type: "error",383 type: "error",
419 title: "Action failed",384 title: "Action failed",
420 description: err instanceof Error ? err.message : "An error occurred.",385 description: err instanceof Error ? err.message : "An error occurred.",
...@@ -449,92 +414,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -449,92 +414,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
449 (actionParams: {414 (actionParams: {
450 action: GitStackedAction;415 action: GitStackedAction;
451 commitMessage?: string;416 commitMessage?: string;
452 forcePushOnlyProgress?: boolean;
453 onConfirmed?: () => void;417 onConfirmed?: () => void;
454 }) => {418 }) => {
455 const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? [];419 void runGitActionWithToast({
456 const providedCommitMessage = actionParams.commitMessage?.trim() || undefined;420 ...actionParams,
457 const shouldSuggestCommitAndBranch =421 featureBranch: true,
458 !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges;422 skipDefaultBranchPrompt: true,
459
460 const checkoutToastId = toastManager.add({
461 type: "loading",
462 title: "Preparing feature branch...",
463 timeout: 0,
464 data: threadToastData,
465 });423 });
466
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 }) => {
492 const statusOverride = gitStatusForActions
493 ? {
494 ...gitStatusForActions,
495 branch: branchName,
496 pr: null,
497 hasUpstream: false,
498 aheadCount: 0,
499 behindCount: 0,
500 }
501 : null;
502 return runGitActionWithToast({
503 ...actionParams,
504 ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}),
505 skipDefaultBranchPrompt: true,
506 statusOverride,
507 isDefaultBranchOverride: false,
508 progressToastId: checkoutToastId,
509 });
510 })
511 .catch((error) => {
512 toastManager.update(checkoutToastId, {
513 type: "error",
514 title: "Failed to checkout feature branch",
515 description: error instanceof Error ? error.message : "An error occurred.",
516 data: threadToastData,
517 });
518 });
519 },424 },
520 [425 [runGitActionWithToast],
521 branchList?.branches,
522 createBranchAndCheckoutMutation,
523 gitStatusForActions,
524 runGitActionWithToast,
525 suggestCommitAndBranchMutation,
526 threadToastData,
527 ],
528 );426 );
529427
530 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {428 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
531 if (!pendingDefaultBranchAction) return;429 if (!pendingDefaultBranchAction) return;
532 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction;430 const { action, commitMessage, onConfirmed } = pendingDefaultBranchAction;
533 setPendingDefaultBranchAction(null);431 setPendingDefaultBranchAction(null);
534 checkoutNewBranchAndRunAction({432 checkoutNewBranchAndRunAction({
535 action,433 action,
536 ...(commitMessage ? { commitMessage } : {}),434 ...(commitMessage ? { commitMessage } : {}),
537 forcePushOnlyProgress,
538 ...(onConfirmed ? { onConfirmed } : {}),435 ...(onConfirmed ? { onConfirmed } : {}),
539 });436 });
540 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);437 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);
apps/web/src/lib/gitReactQuery.test.ts-12
...@@ -4,7 +4,6 @@ import {...@@ -4,7 +4,6 @@ import {
4 gitMutationKeys,4 gitMutationKeys,
5 gitPullMutationOptions,5 gitPullMutationOptions,
6 gitRunStackedActionMutationOptions,6 gitRunStackedActionMutationOptions,
7 gitSuggestCommitAndBranchMutationOptions,
8} from "./gitReactQuery";7} from "./gitReactQuery";
98
10describe("gitMutationKeys", () => {9describe("gitMutationKeys", () => {
...@@ -17,12 +16,6 @@ describe("gitMutationKeys", () => {...@@ -17,12 +16,6 @@ describe("gitMutationKeys", () => {
17 it("scopes pull keys by cwd", () => {16 it("scopes pull keys by cwd", () => {
18 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));17 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
19 });18 });
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 });
26});19});
2720
28describe("git mutation options", () => {21describe("git mutation options", () => {
...@@ -37,9 +30,4 @@ describe("git mutation options", () => {...@@ -37,9 +30,4 @@ describe("git mutation options", () => {
37 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });30 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
38 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));31 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
39 });32 });
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 });
45});33});
apps/web/src/lib/gitReactQuery.ts+3-16
...@@ -19,8 +19,6 @@ export const gitMutationKeys = {...@@ -19,8 +19,6 @@ 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,
24 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,22 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
25};23};
2624
...@@ -118,9 +116,11 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -118,9 +116,11 @@ export function gitRunStackedActionMutationOptions(input: {
118 mutationFn: async ({116 mutationFn: async ({
119 action,117 action,
120 commitMessage,118 commitMessage,
119 featureBranch,
121 }: {120 }: {
122 action: GitStackedAction;121 action: GitStackedAction;
123 commitMessage?: string;122 commitMessage?: string;
123 featureBranch?: boolean;
124 }) => {124 }) => {
125 const api = ensureNativeApi();125 const api = ensureNativeApi();
126 if (!input.cwd) throw new Error("Git action is unavailable.");126 if (!input.cwd) throw new Error("Git action is unavailable.");
...@@ -128,6 +128,7 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -128,6 +128,7 @@ export function gitRunStackedActionMutationOptions(input: {
128 cwd: input.cwd,128 cwd: input.cwd,
129 action,129 action,
130 ...(commitMessage ? { commitMessage } : {}),130 ...(commitMessage ? { commitMessage } : {}),
131 ...(featureBranch ? { featureBranch } : {}),
131 });132 });
132 },133 },
133 onSettled: async () => {134 onSettled: async () => {
...@@ -136,20 +137,6 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -136,20 +137,6 @@ export function gitRunStackedActionMutationOptions(input: {
136 });137 });
137}138}
138139
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
153export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {140export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
154 return mutationOptions({141 return mutationOptions({
155 mutationKey: gitMutationKeys.pull(input.cwd),142 mutationKey: gitMutationKeys.pull(input.cwd),
apps/web/src/wsNativeApi.test.ts-1
...@@ -6,7 +6,6 @@ import {...@@ -6,7 +6,6 @@ import {
6 ProjectId,6 ProjectId,
7 ThreadId,7 ThreadId,
8 WS_CHANNELS,8 WS_CHANNELS,
9 WS_METHODS,
10} from "@t3tools/contracts";9} from "@t3tools/contracts";
11import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";10import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1211
apps/web/src/wsNativeApi.ts-2
...@@ -163,8 +163,6 @@ export function createWsNativeApi(): NativeApi {...@@ -163,8 +163,6 @@ 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),
168 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),166 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
169 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),167 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
170 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),168 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
packages/contracts/src/git.ts+6-12
...@@ -13,6 +13,7 @@ export const GitPushStepStatus = Schema.Literals([...@@ -13,6 +13,7 @@ export const GitPushStepStatus = Schema.Literals([
13 "skipped_not_requested",13 "skipped_not_requested",
14 "skipped_up_to_date",14 "skipped_up_to_date",
15]);15]);
16export const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]);
16export const GitPrStepStatus = Schema.Literals([17export const GitPrStepStatus = Schema.Literals([
17 "created",18 "created",
18 "opened_existing",19 "opened_existing",
...@@ -53,15 +54,10 @@ export const GitRunStackedActionInput = Schema.Struct({...@@ -53,15 +54,10 @@ export const GitRunStackedActionInput = Schema.Struct({
53 cwd: TrimmedNonEmptyStringSchema,54 cwd: TrimmedNonEmptyStringSchema,
54 action: GitStackedAction,55 action: GitStackedAction,
55 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),56 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
57 featureBranch: Schema.optional(Schema.Boolean),
56});58});
57export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;59export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
5860
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
65export const GitListBranchesInput = Schema.Struct({61export const GitListBranchesInput = Schema.Struct({
66 cwd: TrimmedNonEmptyStringSchema,62 cwd: TrimmedNonEmptyStringSchema,
67});63});
...@@ -145,6 +141,10 @@ export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;...@@ -145,6 +141,10 @@ export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
145141
146export const GitRunStackedActionResult = Schema.Struct({142export const GitRunStackedActionResult = Schema.Struct({
147 action: GitStackedAction,143 action: GitStackedAction,
144 branch: Schema.Struct({
145 status: GitBranchStepStatus,
146 name: Schema.optional(TrimmedNonEmptyStringSchema),
147 }),
148 commit: Schema.Struct({148 commit: Schema.Struct({
149 status: GitCommitStepStatus,149 status: GitCommitStepStatus,
150 commitSha: Schema.optional(TrimmedNonEmptyStringSchema),150 commitSha: Schema.optional(TrimmedNonEmptyStringSchema),
...@@ -167,12 +167,6 @@ export const GitRunStackedActionResult = Schema.Struct({...@@ -167,12 +167,6 @@ export const GitRunStackedActionResult = Schema.Struct({
167});167});
168export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;168export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
169169
170export const GitSuggestCommitAndBranchResult = Schema.Struct({
171 commitMessage: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000)),
172 branch: TrimmedNonEmptyStringSchema,
173});
174export type GitSuggestCommitAndBranchResult = typeof GitSuggestCommitAndBranchResult.Type;
175
176export const GitPullResult = Schema.Struct({170export const GitPullResult = Schema.Struct({
177 status: Schema.Literals(["pulled", "skipped_up_to_date"]),171 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
178 branch: TrimmedNonEmptyStringSchema,172 branch: TrimmedNonEmptyStringSchema,
packages/contracts/src/ipc.ts-5
...@@ -11,8 +11,6 @@ import type {...@@ -11,8 +11,6 @@ import type {
11 GitRemoveWorktreeInput,11 GitRemoveWorktreeInput,
12 GitRunStackedActionInput,12 GitRunStackedActionInput,
13 GitRunStackedActionResult,13 GitRunStackedActionResult,
14 GitSuggestCommitAndBranchInput,
15 GitSuggestCommitAndBranchResult,
16 GitStatusInput,14 GitStatusInput,
17 GitStatusResult,15 GitStatusResult,
18} from "./git";16} from "./git";
...@@ -90,9 +88,6 @@ export interface NativeApi {...@@ -90,9 +88,6 @@ export interface NativeApi {
90 pull: (input: GitPullInput) => Promise<GitPullResult>;88 pull: (input: GitPullInput) => Promise<GitPullResult>;
91 status: (input: GitStatusInput) => Promise<GitStatusResult>;89 status: (input: GitStatusInput) => Promise<GitStatusResult>;
92 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;90 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
93 suggestCommitAndBranch: (
94 input: GitSuggestCommitAndBranchInput,
95 ) => Promise<GitSuggestCommitAndBranchResult>;
96 };91 };
97 contextMenu: {92 contextMenu: {
98 show: <T extends string>(93 show: <T extends string>(
packages/contracts/src/ws.ts-3
...@@ -18,7 +18,6 @@ import {...@@ -18,7 +18,6 @@ import {
18 GitPullInput,18 GitPullInput,
19 GitRemoveWorktreeInput,19 GitRemoveWorktreeInput,
20 GitRunStackedActionInput,20 GitRunStackedActionInput,
21 GitSuggestCommitAndBranchInput,
22 GitStatusInput,21 GitStatusInput,
23} from "./git";22} from "./git";
24import {23import {
...@@ -49,7 +48,6 @@ export const WS_METHODS = {...@@ -49,7 +48,6 @@ export const WS_METHODS = {
49 gitPull: "git.pull",48 gitPull: "git.pull",
50 gitStatus: "git.status",49 gitStatus: "git.status",
51 gitRunStackedAction: "git.runStackedAction",50 gitRunStackedAction: "git.runStackedAction",
52 gitSuggestCommitAndBranch: "git.suggestCommitAndBranch",
53 gitListBranches: "git.listBranches",51 gitListBranches: "git.listBranches",
54 gitCreateWorktree: "git.createWorktree",52 gitCreateWorktree: "git.createWorktree",
55 gitRemoveWorktree: "git.removeWorktree",53 gitRemoveWorktree: "git.removeWorktree",
...@@ -111,7 +109,6 @@ export const WebSocketRequestBody = Schema.Union([...@@ -111,7 +109,6 @@ export const WebSocketRequestBody = Schema.Union([
111 tagRequestBody(WS_METHODS.gitPull, GitPullInput),109 tagRequestBody(WS_METHODS.gitPull, GitPullInput),
112 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),110 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),
113 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),111 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
114 tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput),
115 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),112 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
116 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),113 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
117 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),114 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
packages/shared/src/git.ts+33
...@@ -31,3 +31,36 @@ export function sanitizeFeatureBranchName(raw: string): string {...@@ -31,3 +31,36 @@ export function sanitizeFeatureBranchName(raw: string): string {
31 }31 }
32 return `feature/${sanitized}`;32 return `feature/${sanitized}`;
33}33}
34
35const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
36
37/**
38 * Resolve a unique `feature/…` branch name that doesn't collide with
39 * any existing branch. Appends a numeric suffix when needed.
40 */
41export function resolveAutoFeatureBranchName(
42 existingBranchNames: readonly string[],
43 preferredBranch?: string,
44): string {
45 const preferred = preferredBranch?.trim();
46 const normalized = sanitizeBranchFragment(
47 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
48 );
49 const resolvedBase = normalized.includes("/")
50 ? normalized.startsWith("feature/")
51 ? normalized
52 : `feature/${normalized}`
53 : `feature/${normalized}`;
54 const existingNames = new Set(existingBranchNames);
55
56 if (!existingNames.has(resolvedBase)) {
57 return resolvedBase;
58 }
59
60 let suffix = 2;
61 while (existingNames.has(`${resolvedBase}-${suffix}`)) {
62 suffix += 1;
63 }
64
65 return `${resolvedBase}-${suffix}`;
66}