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) =>
110110 createBranch: (input) => core.createBranch(input),
111111 checkoutBranch: (input) => core.checkoutBranch(input),
112112 initRepo: (input) => core.initRepo(input),
113 listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd),
113114 } satisfies GitCoreShape;
114115 });
115116
apps/server/src/git/Layers/GitCore.ts+15
......@@ -1172,6 +1172,20 @@ const makeGitCore = Effect.gen(function* () {
11721172 fallbackErrorMessage: "git init failed",
11731173 }).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
11751189 return {
11761190 status,
11771191 statusDetails,
......@@ -1188,6 +1202,7 @@ const makeGitCore = Effect.gen(function* () {
11881202 createBranch,
11891203 checkoutBranch,
11901204 initRepo,
1205 listLocalBranchNames,
11911206 } satisfies GitCoreShape;
11921207});
11931208
apps/server/src/git/Layers/GitManager.test.ts+39-26
......@@ -290,21 +290,12 @@ function runStackedAction(
290290 cwd: string;
291291 action: "commit" | "commit_push" | "commit_push_pr";
292292 commitMessage?: string;
293 featureBranch?: boolean;
293294 },
294295) {
295296 return manager.runStackedAction(input);
296297}
297298
298function suggestCommitAndBranch(
299 manager: GitManagerShape,
300 input: {
301 cwd: string;
302 commitMessage?: string;
303 },
304) {
305 return manager.suggestCommitAndBranch(input);
306}
307
308299function makeManager(input?: {
309300 ghScenario?: FakeGhScenario;
310301 textGeneration?: Partial<FakeGitTextGeneration>;
......@@ -492,6 +483,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
492483 action: "commit",
493484 });
494485
486 expect(result.branch.status).toBe("skipped_not_requested");
495487 expect(result.commit.status).toBe("created");
496488 expect(result.push.status).toBe("skipped_not_requested");
497489 expect(result.pr.status).toBe("skipped_not_requested");
......@@ -529,6 +521,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
529521 commitMessage: "feat: custom summary line\n\n- details from user",
530522 });
531523
524 expect(result.branch.status).toBe("skipped_not_requested");
532525 expect(result.commit.status).toBe("created");
533526 expect(result.commit.subject).toBe("feat: custom summary line");
534527 expect(generatedCount).toBe(0);
......@@ -545,29 +538,39 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
545538 }),
546539 );
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", () =>
549542 Effect.gen(function* () {
550543 const repoDir = yield* makeTempDir("t3code-git-manager-");
551544 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
554550 const { manager } = yield* makeManager();
555 const suggestion = yield* suggestCommitAndBranch(manager, {
551 const result = yield* runStackedAction(manager, {
556552 cwd: repoDir,
553 action: "commit_push",
554 featureBranch: true,
557555 });
558556
559 expect(suggestion).toEqual({
560 commitMessage: "Implement stacked git actions",
561 branch: "feature/implement-stacked-git-actions",
562 });
557 expect(result.branch.status).toBe("created");
558 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");
559 expect(result.commit.status).toBe("created");
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");
563566 }),
564567 );
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", () =>
567570 Effect.gen(function* () {
568571 const repoDir = yield* makeTempDir("t3code-git-manager-");
569572 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");
571574 let generatedCount = 0;
572575
573576 const { manager } = yield* makeManager({
......@@ -583,15 +586,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
583586 }),
584587 },
585588 });
586 const suggestion = yield* suggestCommitAndBranch(manager, {
589 const result = yield* runStackedAction(manager, {
587590 cwd: repoDir,
591 action: "commit",
592 featureBranch: true,
588593 commitMessage: "feat: custom summary line\n\n- details from user",
589594 });
590595
591 expect(suggestion).toEqual({
592 commitMessage: "feat: custom summary line\n\n- details from user",
593 branch: "feature/feat-custom-summary-line",
594 });
596 expect(result.branch.status).toBe("created");
597 expect(result.branch.name).toBe("feature/feat-custom-summary-line");
598 expect(result.commit.status).toBe("created");
599 expect(result.commit.subject).toBe("feat: custom summary line");
595600 expect(generatedCount).toBe(0);
596601 }),
597602 );
......@@ -607,20 +612,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
607612 action: "commit",
608613 });
609614
615 expect(result.branch.status).toBe("skipped_not_requested");
610616 expect(result.commit.status).toBe("skipped_no_changes");
611617 expect(result.push.status).toBe("skipped_not_requested");
612618 expect(result.pr.status).toBe("skipped_not_requested");
613619 }),
614620 );
615621
616 it.effect("returns a clear error when suggesting from clean worktree", () =>
622 it.effect("featureBranch returns error when worktree is clean", () =>
617623 Effect.gen(function* () {
618624 const repoDir = yield* makeTempDir("t3code-git-manager-");
619625 yield* initRepo(repoDir);
620626
621627 const { manager } = yield* makeManager();
622 const errorMessage = yield* suggestCommitAndBranch(manager, {
628 const errorMessage = yield* runStackedAction(manager, {
623629 cwd: repoDir,
630 action: "commit",
631 featureBranch: true,
624632 }).pipe(
625633 Effect.flip,
626634 Effect.map((error) => error.message),
......@@ -645,6 +653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
645653 action: "commit_push",
646654 });
647655
656 expect(result.branch.status).toBe("skipped_not_requested");
648657 expect(result.commit.status).toBe("created");
649658 expect(result.push.status).toBe("pushed");
650659 expect(result.push.setUpstream).toBe(true);
......@@ -689,6 +698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
689698 action: "commit_push_pr",
690699 });
691700
701 expect(result.branch.status).toBe("skipped_not_requested");
692702 expect(result.commit.status).toBe("created");
693703 expect(result.push.status).toBe("pushed");
694704 expect(result.push.setUpstream).toBe(true);
......@@ -721,6 +731,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
721731 action: "commit_push",
722732 });
723733
734 expect(result.branch.status).toBe("skipped_not_requested");
724735 expect(result.commit.status).toBe("skipped_no_changes");
725736 expect(result.push.status).toBe("skipped_up_to_date");
726737 }),
......@@ -755,6 +766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
755766 action: "commit_push_pr",
756767 });
757768
769 expect(result.branch.status).toBe("skipped_not_requested");
758770 expect(result.pr.status).toBe("opened_existing");
759771 expect(result.pr.number).toBe(42);
760772 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
......@@ -795,6 +807,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
795807 action: "commit_push_pr",
796808 });
797809
810 expect(result.branch.status).toBe("skipped_not_requested");
798811 expect(result.pr.status).toBe("created");
799812 expect(result.pr.number).toBe(88);
800813 expect(
apps/server/src/git/Layers/GitManager.ts+41-19
......@@ -1,7 +1,7 @@
11import { randomUUID } from "node:crypto";
22
33import { Effect, FileSystem, Layer, Path } from "effect";
4import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
4import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
55
66import { GitManagerError } from "../Errors.ts";
77import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
......@@ -431,28 +431,33 @@ export const makeGitManager = Effect.gen(function* () {
431431 };
432432 });
433433
434 const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced(
435 function* (input) {
436 const details = yield* gitCore.statusDetails(input.cwd);
434 const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) =>
435 Effect.gen(function* () {
437436 const suggestion = yield* resolveCommitAndBranchSuggestion({
438 cwd: input.cwd,
439 branch: details.branch,
440 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
437 cwd,
438 branch,
439 ...(commitMessage ? { commitMessage } : {}),
441440 includeBranch: true,
442441 });
443442 if (!suggestion) {
444443 return yield* gitManagerError(
445 "suggestCommitAndBranch",
446 "Cannot suggest a commit and branch because there are no changes to commit.",
444 "runFeatureBranchStep",
445 "Cannot create a feature branch because there are no changes to commit.",
447446 );
448447 }
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
450456 return {
451 commitMessage: suggestion.commitMessage,
452 branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject),
457 branchStep: { status: "created" as const, name: resolvedBranch },
458 resolvedCommitMessage: suggestion.commitMessage,
453459 };
454 },
455 );
460 });
456461
457462 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(
458463 function* (input) {
......@@ -460,28 +465,46 @@ export const makeGitManager = Effect.gen(function* () {
460465 const wantsPr = input.action === "commit_push_pr";
461466
462467 const initialStatus = yield* gitCore.statusDetails(input.cwd);
463 if (wantsPush && !initialStatus.branch) {
468 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
464469 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
465470 }
466 if (wantsPr && !initialStatus.branch) {
471 if (!input.featureBranch && wantsPr && !initialStatus.branch) {
467472 return yield* gitManagerError(
468473 "runStackedAction",
469474 "Cannot create a pull request from detached HEAD.",
470475 );
471476 }
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
475497 const push = wantsPush
476 ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)
498 ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch)
477499 : { status: "skipped_not_requested" as const };
478500
479501 const pr = wantsPr
480 ? yield* runPrStep(input.cwd, initialStatus.branch)
502 ? yield* runPrStep(input.cwd, currentBranch)
481503 : { status: "skipped_not_requested" as const };
482504
483505 return {
484506 action: input.action,
507 branch: branchStep,
485508 commit,
486509 push,
487510 pr,
......@@ -492,7 +515,6 @@ export const makeGitManager = Effect.gen(function* () {
492515 return {
493516 status,
494517 runStackedAction,
495 suggestCommitAndBranch,
496518 } satisfies GitManagerShape;
497519});
498520
apps/server/src/git/Services/GitCore.ts+5
......@@ -157,6 +157,11 @@ export interface GitCoreShape {
157157 * Initialize a repository in the provided directory.
158158 */
159159 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>;
160165}
161166
162167/**
apps/server/src/git/Services/GitManager.ts+1-9
......@@ -9,8 +9,6 @@
99import {
1010 GitRunStackedActionInput,
1111 GitRunStackedActionResult,
12 GitSuggestCommitAndBranchInput,
13 GitSuggestCommitAndBranchResult,
1412 GitStatusInput,
1513 GitStatusResult,
1614} from "@t3tools/contracts";
......@@ -31,17 +29,11 @@ export interface GitManagerShape {
3129
3230 /**
3331 * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`).
32 * When `featureBranch` is set, creates and checks out a feature branch first.
3433 */
3534 readonly runStackedAction: (
3635 input: GitRunStackedActionInput,
3736 ) => 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>;
4537}
4638
4739/**
apps/server/src/wsServer.test.ts+1-31
......@@ -1555,8 +1555,7 @@ describe("WebSocket Server", () => {
15551555
15561556 const status = vi.fn(() => Effect.succeed(statusResult));
15571557 const runStackedAction = vi.fn(() => Effect.void as any);
1558 const suggestCommitAndBranch = vi.fn(() => Effect.void as any);
1559 const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch };
1558 const gitManager: GitManagerShape = { status, runStackedAction };
15601559
15611560 server = await createTestServer({ cwd: "/test", gitManager });
15621561 const addr = server.address();
......@@ -1586,7 +1585,6 @@ describe("WebSocket Server", () => {
15861585 const gitManager: GitManagerShape = {
15871586 status: vi.fn(() => Effect.void as any),
15881587 runStackedAction,
1589 suggestCommitAndBranch: vi.fn(() => Effect.void as any),
15901588 };
15911589
15921590 server = await createTestServer({ cwd: "/test", gitManager });
......@@ -1609,34 +1607,6 @@ describe("WebSocket Server", () => {
16091607 });
16101608 });
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
16401610 it("rejects websocket connections without a valid auth token", async () => {
16411611 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
16421612 const addr = server.address();
apps/server/src/wsServer.ts-5
......@@ -668,11 +668,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
668668 return yield* gitManager.runStackedAction(body);
669669 }
670670
671 case WS_METHODS.gitSuggestCommitAndBranch: {
672 const body = stripRequestTag(request.body);
673 return yield* gitManager.suggestCommitAndBranch(body);
674 }
675
676671 case WS_METHODS.gitListBranches: {
677672 const body = stripRequestTag(request.body);
678673 return yield* git.listBranches(body);
apps/web/src/components/GitActionsControl.logic.test.ts+4
......@@ -823,6 +823,7 @@ describe("summarizeGitResult", () => {
823823 it("returns commit-focused toast for commit action", () => {
824824 const result = summarizeGitResult({
825825 action: "commit",
826 branch: { status: "skipped_not_requested" },
826827 commit: {
827828 status: "created",
828829 commitSha: "0123456789abcdef",
......@@ -841,6 +842,7 @@ describe("summarizeGitResult", () => {
841842 it("returns push-focused toast for push action", () => {
842843 const result = summarizeGitResult({
843844 action: "commit_push",
845 branch: { status: "skipped_not_requested" },
844846 commit: {
845847 status: "created",
846848 commitSha: "abcdef0123456789",
......@@ -863,6 +865,7 @@ describe("summarizeGitResult", () => {
863865 it("returns PR-focused toast for created PR action", () => {
864866 const result = summarizeGitResult({
865867 action: "commit_push_pr",
868 branch: { status: "skipped_not_requested" },
866869 commit: {
867870 status: "created",
868871 commitSha: "89abcdef01234567",
......@@ -888,6 +891,7 @@ describe("summarizeGitResult", () => {
888891 it("truncates long description text", () => {
889892 const result = summarizeGitResult({
890893 action: "commit_push_pr",
894 branch: { status: "skipped_not_requested" },
891895 commit: {
892896 status: "created",
893897 commitSha: "89abcdef01234567",
apps/web/src/components/GitActionsControl.logic.ts+7-31
......@@ -3,7 +3,6 @@ import type {
33 GitStackedAction,
44 GitStatusResult,
55} from "@t3tools/contracts";
6import { sanitizeBranchFragment } from "@t3tools/shared/git";
76
87export type GitActionIconName = "commit" | "push" | "pr";
98
......@@ -36,7 +35,6 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";
3635
3736const SHORT_SHA_LENGTH = 7;
3837const TOAST_DESCRIPTION_MAX = 72;
39const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
4038
4139function shortenSha(sha: string | undefined): string | null {
4240 if (!sha) return null;
......@@ -59,7 +57,9 @@ export function buildGitActionProgressStages(input: {
5957 hasWorkingTreeChanges: boolean;
6058 forcePushOnly?: boolean;
6159 pushTarget?: string;
60 featureBranch?: boolean;
6261}): string[] {
62 const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
6363 const shouldIncludeCommitStages =
6464 !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges);
6565 const commitStages = !shouldIncludeCommitStages
......@@ -69,12 +69,12 @@ export function buildGitActionProgressStages(input: {
6969 : ["Generating commit message...", "Committing..."];
7070 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
7171 if (input.action === "commit") {
72 return commitStages;
72 return [...branchStages, ...commitStages];
7373 }
7474 if (input.action === "commit_push") {
75 return [...commitStages, pushStage];
75 return [...branchStages, ...commitStages, pushStage];
7676 }
77 return [...commitStages, pushStage, "Creating PR..."];
77 return [...branchStages, ...commitStages, pushStage, "Creating PR..."];
7878}
7979
8080const withDescription = (title: string, description: string | undefined) =>
......@@ -322,29 +322,5 @@ export function resolveDefaultBranchActionDialogCopy(input: {
322322 };
323323}
324324
325export function resolveAutoFeatureBranchName(
326 existingBranchNames: readonly string[],
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}
325// Re-export from shared for backwards compatibility in this module's exports
326export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
apps/web/src/components/GitActionsControl.tsx+26-129
......@@ -1,4 +1,4 @@
1import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts";
1import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts";
22import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useCallback, useEffect, useMemo, useState } from "react";
44import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
......@@ -11,7 +11,6 @@ import {
1111 type GitQuickAction,
1212 type DefaultBranchConfirmableAction,
1313 requiresDefaultBranchConfirmation,
14 resolveAutoFeatureBranchName,
1514 resolveDefaultBranchActionDialogCopy,
1615 resolveQuickAction,
1716 summarizeGitResult,
......@@ -34,12 +33,10 @@ import { Textarea } from "~/components/ui/textarea";
3433import { toastManager } from "~/components/ui/toast";
3534import {
3635 gitBranchesQueryOptions,
37 gitCreateBranchAndCheckoutMutationOptions,
3836 gitInitMutationOptions,
3937 gitMutationKeys,
4038 gitPullMutationOptions,
4139 gitRunStackedActionMutationOptions,
42 gitSuggestCommitAndBranchMutationOptions,
4340 gitStatusQueryOptions,
4441 invalidateGitQueries,
4542} from "~/lib/gitReactQuery";
......@@ -60,8 +57,6 @@ interface PendingDefaultBranchAction {
6057 onConfirmed?: () => void;
6158}
6259
63type GitActionToastId = ReturnType<typeof toastManager.add>;
64
6560function getMenuActionDisabledReason(
6661 item: GitActionMenuItem,
6762 gitStatus: GitStatusResult | null,
......@@ -173,26 +168,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
173168 const runImmediateGitActionMutation = useMutation(
174169 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
175170 );
176 const suggestCommitAndBranchMutation = useMutation(
177 gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }),
178 );
179 const createBranchAndCheckoutMutation = useMutation(
180 gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }),
181 );
182171 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
183172
184173 const isRunStackedActionRunning =
185174 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;
190175 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
191 const isGitActionRunning =
192 isRunStackedActionRunning ||
193 isSuggestCommitAndBranchRunning ||
194 isCreateBranchAndCheckoutRunning ||
195 isPullRunning;
176 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
196177 const isDefaultBranch = useMemo(() => {
197178 const branchName = gitStatusForActions?.branch;
198179 if (!branchName) return false;
......@@ -255,26 +236,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
255236 forcePushOnlyProgress = false,
256237 onConfirmed,
257238 skipDefaultBranchPrompt = false,
258 statusOverride,
259 isDefaultBranchOverride,
260 progressToastId,
239 featureBranch = false,
261240 }: {
262241 action: GitStackedAction;
263242 commitMessage?: string;
264243 forcePushOnlyProgress?: boolean;
265244 onConfirmed?: () => void;
266245 skipDefaultBranchPrompt?: boolean;
267 statusOverride?: GitStatusResult | null;
268 isDefaultBranchOverride?: boolean;
269 progressToastId?: GitActionToastId;
246 featureBranch?: boolean;
270247 }) => {
271 const actionStatus = statusOverride ?? gitStatusForActions;
272 const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch;
248 const actionStatus = gitStatusForActions;
249 const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch;
273250 const actionBranch = actionStatus?.branch ?? null;
274251 const includesCommit =
275252 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);
276253 if (
277254 !skipDefaultBranchPrompt &&
255 !featureBranch &&
278256 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&
279257 actionBranch
280258 ) {
......@@ -293,36 +271,26 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
293271 }
294272 onConfirmed?.();
295273
296 const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined;
274 const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined;
297275 const progressStages = buildGitActionProgressStages({
298276 action,
299277 hasCustomCommitMessage: !!commitMessage?.trim(),
300278 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
301279 forcePushOnly: forcePushOnlyProgress,
280 featureBranch,
302281 ...(pushTarget ? { pushTarget } : {}),
303282 });
304 const resolvedProgressToastId =
305 progressToastId ??
306 toastManager.add({
307 type: "loading",
308 title: progressStages[0] ?? "Running git action...",
309 timeout: 0,
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 }
283 const progressToastId = toastManager.add({
284 type: "loading",
285 title: progressStages[0] ?? "Running git action...",
286 timeout: 0,
287 data: threadToastData,
288 });
321289
322290 let stageIndex = 0;
323291 const stageInterval = setInterval(() => {
324292 stageIndex = Math.min(stageIndex + 1, progressStages.length - 1);
325 toastManager.update(resolvedProgressToastId, {
293 toastManager.update(progressToastId, {
326294 title: progressStages[stageIndex] ?? "Running git action...",
327295 type: "loading",
328296 timeout: 0,
......@@ -337,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
337305 const promise = runImmediateGitActionMutation.mutateAsync({
338306 action,
339307 ...(commitMessage ? { commitMessage } : {}),
308 ...(featureBranch ? { featureBranch } : {}),
340309 });
341310
342311 try {
......@@ -357,10 +326,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
357326 result.push.status === "pushed" &&
358327 !actionIsDefaultBranch;
359328 const closeResultToast = () => {
360 toastManager.close(resolvedProgressToastId);
329 toastManager.close(progressToastId);
361330 };
362331
363 toastManager.update(resolvedProgressToastId, {
332 toastManager.update(progressToastId, {
364333 type: "success",
365334 title: resultToast.title,
366335 description: resultToast.description,
......@@ -378,8 +347,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
378347 action: "commit_push",
379348 forcePushOnlyProgress: true,
380349 onConfirmed: closeResultToast,
381 statusOverride: actionStatus,
382 isDefaultBranchOverride: actionIsDefaultBranch,
383350 });
384351 },
385352 },
......@@ -404,8 +371,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
404371 closeResultToast();
405372 void runGitActionWithToast({
406373 action: "commit_push_pr",
407 statusOverride: actionStatus,
408 isDefaultBranchOverride: actionIsDefaultBranch,
409374 });
410375 },
411376 },
......@@ -414,7 +379,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
414379 });
415380 } catch (err) {
416381 stopProgressUpdates();
417 toastManager.update(resolvedProgressToastId, {
382 toastManager.update(progressToastId, {
418383 type: "error",
419384 title: "Action failed",
420385 description: err instanceof Error ? err.message : "An error occurred.",
......@@ -449,92 +414,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
449414 (actionParams: {
450415 action: GitStackedAction;
451416 commitMessage?: string;
452 forcePushOnlyProgress?: boolean;
453417 onConfirmed?: () => void;
454418 }) => {
455 const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? [];
456 const providedCommitMessage = actionParams.commitMessage?.trim() || undefined;
457 const shouldSuggestCommitAndBranch =
458 !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges;
459
460 const checkoutToastId = toastManager.add({
461 type: "loading",
462 title: "Preparing feature branch...",
463 timeout: 0,
464 data: threadToastData,
419 void runGitActionWithToast({
420 ...actionParams,
421 featureBranch: true,
422 skipDefaultBranchPrompt: true,
465423 });
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 });
519424 },
520 [
521 branchList?.branches,
522 createBranchAndCheckoutMutation,
523 gitStatusForActions,
524 runGitActionWithToast,
525 suggestCommitAndBranchMutation,
526 threadToastData,
527 ],
425 [runGitActionWithToast],
528426 );
529427
530428 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
531429 if (!pendingDefaultBranchAction) return;
532 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction;
430 const { action, commitMessage, onConfirmed } = pendingDefaultBranchAction;
533431 setPendingDefaultBranchAction(null);
534432 checkoutNewBranchAndRunAction({
535433 action,
536434 ...(commitMessage ? { commitMessage } : {}),
537 forcePushOnlyProgress,
538435 ...(onConfirmed ? { onConfirmed } : {}),
539436 });
540437 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);
apps/web/src/lib/gitReactQuery.test.ts-12
......@@ -4,7 +4,6 @@ import {
44 gitMutationKeys,
55 gitPullMutationOptions,
66 gitRunStackedActionMutationOptions,
7 gitSuggestCommitAndBranchMutationOptions,
87} from "./gitReactQuery";
98
109describe("gitMutationKeys", () => {
......@@ -17,12 +16,6 @@ describe("gitMutationKeys", () => {
1716 it("scopes pull keys by cwd", () => {
1817 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
1918 });
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 });
2619});
2720
2821describe("git mutation options", () => {
......@@ -37,9 +30,4 @@ describe("git mutation options", () => {
3730 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
3831 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
3932 });
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 });
4533});
apps/web/src/lib/gitReactQuery.ts+3-16
......@@ -19,8 +19,6 @@ export const gitMutationKeys = {
1919 createBranchAndCheckout: (cwd: string | null) =>
2020 ["git", "mutation", "create-branch-and-checkout", cwd] as const,
2121 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,
22 suggestCommitAndBranch: (cwd: string | null) =>
23 ["git", "mutation", "suggest-commit-and-branch", cwd] as const,
2422 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
2523};
2624
......@@ -118,9 +116,11 @@ export function gitRunStackedActionMutationOptions(input: {
118116 mutationFn: async ({
119117 action,
120118 commitMessage,
119 featureBranch,
121120 }: {
122121 action: GitStackedAction;
123122 commitMessage?: string;
123 featureBranch?: boolean;
124124 }) => {
125125 const api = ensureNativeApi();
126126 if (!input.cwd) throw new Error("Git action is unavailable.");
......@@ -128,6 +128,7 @@ export function gitRunStackedActionMutationOptions(input: {
128128 cwd: input.cwd,
129129 action,
130130 ...(commitMessage ? { commitMessage } : {}),
131 ...(featureBranch ? { featureBranch } : {}),
131132 });
132133 },
133134 onSettled: async () => {
......@@ -136,20 +137,6 @@ export function gitRunStackedActionMutationOptions(input: {
136137 });
137138}
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
153140export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
154141 return mutationOptions({
155142 mutationKey: gitMutationKeys.pull(input.cwd),
apps/web/src/wsNativeApi.test.ts-1
......@@ -6,7 +6,6 @@ import {
66 ProjectId,
77 ThreadId,
88 WS_CHANNELS,
9 WS_METHODS,
109} from "@t3tools/contracts";
1110import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1211
apps/web/src/wsNativeApi.ts-2
......@@ -163,8 +163,6 @@ export function createWsNativeApi(): NativeApi {
163163 pull: (input) => transport.request(WS_METHODS.gitPull, input),
164164 status: (input) => transport.request(WS_METHODS.gitStatus, input),
165165 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
166 suggestCommitAndBranch: (input) =>
167 transport.request(WS_METHODS.gitSuggestCommitAndBranch, input),
168166 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
169167 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
170168 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
packages/contracts/src/git.ts+6-12
......@@ -13,6 +13,7 @@ export const GitPushStepStatus = Schema.Literals([
1313 "skipped_not_requested",
1414 "skipped_up_to_date",
1515]);
16export const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]);
1617export const GitPrStepStatus = Schema.Literals([
1718 "created",
1819 "opened_existing",
......@@ -53,15 +54,10 @@ export const GitRunStackedActionInput = Schema.Struct({
5354 cwd: TrimmedNonEmptyStringSchema,
5455 action: GitStackedAction,
5556 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
57 featureBranch: Schema.optional(Schema.Boolean),
5658});
5759export 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
6561export const GitListBranchesInput = Schema.Struct({
6662 cwd: TrimmedNonEmptyStringSchema,
6763});
......@@ -145,6 +141,10 @@ export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
145141
146142export const GitRunStackedActionResult = Schema.Struct({
147143 action: GitStackedAction,
144 branch: Schema.Struct({
145 status: GitBranchStepStatus,
146 name: Schema.optional(TrimmedNonEmptyStringSchema),
147 }),
148148 commit: Schema.Struct({
149149 status: GitCommitStepStatus,
150150 commitSha: Schema.optional(TrimmedNonEmptyStringSchema),
......@@ -167,12 +167,6 @@ export const GitRunStackedActionResult = Schema.Struct({
167167});
168168export 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
176170export const GitPullResult = Schema.Struct({
177171 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
178172 branch: TrimmedNonEmptyStringSchema,
packages/contracts/src/ipc.ts-5
......@@ -11,8 +11,6 @@ import type {
1111 GitRemoveWorktreeInput,
1212 GitRunStackedActionInput,
1313 GitRunStackedActionResult,
14 GitSuggestCommitAndBranchInput,
15 GitSuggestCommitAndBranchResult,
1614 GitStatusInput,
1715 GitStatusResult,
1816} from "./git";
......@@ -90,9 +88,6 @@ export interface NativeApi {
9088 pull: (input: GitPullInput) => Promise<GitPullResult>;
9189 status: (input: GitStatusInput) => Promise<GitStatusResult>;
9290 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
93 suggestCommitAndBranch: (
94 input: GitSuggestCommitAndBranchInput,
95 ) => Promise<GitSuggestCommitAndBranchResult>;
9691 };
9792 contextMenu: {
9893 show: <T extends string>(
packages/contracts/src/ws.ts-3
......@@ -18,7 +18,6 @@ import {
1818 GitPullInput,
1919 GitRemoveWorktreeInput,
2020 GitRunStackedActionInput,
21 GitSuggestCommitAndBranchInput,
2221 GitStatusInput,
2322} from "./git";
2423import {
......@@ -49,7 +48,6 @@ export const WS_METHODS = {
4948 gitPull: "git.pull",
5049 gitStatus: "git.status",
5150 gitRunStackedAction: "git.runStackedAction",
52 gitSuggestCommitAndBranch: "git.suggestCommitAndBranch",
5351 gitListBranches: "git.listBranches",
5452 gitCreateWorktree: "git.createWorktree",
5553 gitRemoveWorktree: "git.removeWorktree",
......@@ -111,7 +109,6 @@ export const WebSocketRequestBody = Schema.Union([
111109 tagRequestBody(WS_METHODS.gitPull, GitPullInput),
112110 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),
113111 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
114 tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput),
115112 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
116113 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
117114 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
packages/shared/src/git.ts+33
......@@ -31,3 +31,36 @@ export function sanitizeFeatureBranchName(raw: string): string {
3131 }
3232 return `feature/${sanitized}`;
3333}
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}