From 906550f9af7d6fc37e1c9e4f21018dcb9ea03591 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 13:10:12 -0800 Subject: [PATCH] Merge feature-branch checkout into atomic runStackedAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/git/Layers/GitCore.test.ts | 1 + apps/server/src/git/Layers/GitCore.ts | 15 ++ apps/server/src/git/Layers/GitManager.test.ts | 65 +++++--- apps/server/src/git/Layers/GitManager.ts | 60 ++++--- apps/server/src/git/Services/GitCore.ts | 5 + apps/server/src/git/Services/GitManager.ts | 10 +- apps/server/src/wsServer.test.ts | 32 +--- apps/server/src/wsServer.ts | 5 - .../GitActionsControl.logic.test.ts | 4 + .../src/components/GitActionsControl.logic.ts | 38 +---- apps/web/src/components/GitActionsControl.tsx | 155 +++--------------- apps/web/src/lib/gitReactQuery.test.ts | 12 -- apps/web/src/lib/gitReactQuery.ts | 19 +-- apps/web/src/wsNativeApi.test.ts | 1 - apps/web/src/wsNativeApi.ts | 2 - packages/contracts/src/git.ts | 18 +- packages/contracts/src/ipc.ts | 5 - packages/contracts/src/ws.ts | 3 - packages/shared/src/git.ts | 33 ++++ 19 files changed, 182 insertions(+), 301 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 8e12cd0f187bd337c760a34f2baafe4f063122ff..0c0e914032438760727a8ccdbd7bdd79499c3ff5 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -110,6 +110,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => createBranch: (input) => core.createBranch(input), checkoutBranch: (input) => core.checkoutBranch(input), initRepo: (input) => core.initRepo(input), + listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd), } satisfies GitCoreShape; }); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index f62f9dc9f0cd78a1c3b8c1f9bd11396c4d58ba7e..a288b2f3799cabd16d86e244c0fa0a2dc1d4855c 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1172,6 +1172,20 @@ const makeGitCore = Effect.gen(function* () { fallbackErrorMessage: "git init failed", }).pipe(Effect.asVoid); + const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => + runGitStdout("GitCore.listLocalBranchNames", cwd, [ + "branch", + "--list", + "--format=%(refname:short)", + ]).pipe( + Effect.map((stdout) => + stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + ); + return { status, statusDetails, @@ -1188,6 +1202,7 @@ const makeGitCore = Effect.gen(function* () { createBranch, checkoutBranch, initRepo, + listLocalBranchNames, } satisfies GitCoreShape; }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index d0720c12f36278e3a52d3fc62c166284af5812a8..f94f4ad39b6fc8432de5423d63c2f2dc9a89a0fe 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -290,21 +290,12 @@ function runStackedAction( cwd: string; action: "commit" | "commit_push" | "commit_push_pr"; commitMessage?: string; + featureBranch?: boolean; }, ) { return manager.runStackedAction(input); } -function suggestCommitAndBranch( - manager: GitManagerShape, - input: { - cwd: string; - commitMessage?: string; - }, -) { - return manager.suggestCommitAndBranch(input); -} - function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; @@ -492,6 +483,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { action: "commit", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); @@ -529,6 +521,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { commitMessage: "feat: custom summary line\n\n- details from user", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.commit.subject).toBe("feat: custom summary line"); expect(generatedCount).toBe(0); @@ -545,29 +538,39 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("suggests commit message and semantic branch for dirty worktree", () => + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nsuggest\n"); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); const { manager } = yield* makeManager(); - const suggestion = yield* suggestCommitAndBranch(manager, { + const result = yield* runStackedAction(manager, { cwd: repoDir, + action: "commit_push", + featureBranch: true, }); - expect(suggestion).toEqual({ - commitMessage: "Implement stacked git actions", - branch: "feature/implement-stacked-git-actions", - }); + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("feature/implement-stacked-git-actions"); }), ); - it.effect("suggests branch from custom commit message without AI generation", () => + it.effect("featureBranch uses custom commit message and derives branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-suggest\n"); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -583,15 +586,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), }, }); - const suggestion = yield* suggestCommitAndBranch(manager, { + const result = yield* runStackedAction(manager, { cwd: repoDir, + action: "commit", + featureBranch: true, commitMessage: "feat: custom summary line\n\n- details from user", }); - expect(suggestion).toEqual({ - commitMessage: "feat: custom summary line\n\n- details from user", - branch: "feature/feat-custom-summary-line", - }); + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/feat-custom-summary-line"); + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("feat: custom summary line"); expect(generatedCount).toBe(0); }), ); @@ -607,20 +612,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { action: "commit", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("skipped_no_changes"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); }), ); - it.effect("returns a clear error when suggesting from clean worktree", () => + it.effect("featureBranch returns error when worktree is clean", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const { manager } = yield* makeManager(); - const errorMessage = yield* suggestCommitAndBranch(manager, { + const errorMessage = yield* runStackedAction(manager, { cwd: repoDir, + action: "commit", + featureBranch: true, }).pipe( Effect.flip, Effect.map((error) => error.message), @@ -645,6 +653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { action: "commit_push", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); expect(result.push.setUpstream).toBe(true); @@ -689,6 +698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { action: "commit_push_pr", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); expect(result.push.setUpstream).toBe(true); @@ -721,6 +731,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { action: "commit_push", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.commit.status).toBe("skipped_no_changes"); expect(result.push.status).toBe("skipped_up_to_date"); }), @@ -755,6 +766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { action: "commit_push_pr", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(42); expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); @@ -795,6 +807,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { action: "commit_push_pr", }); + expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); expect( diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 643f276f9a2dffb23bad877983e89c51e6e8f1b8..0122e26724ddf53848c4f11ef994201b12af21d9 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { GitManagerError } from "../Errors.ts"; import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; @@ -431,28 +431,33 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced( - function* (input) { - const details = yield* gitCore.statusDetails(input.cwd); + const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => + Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd: input.cwd, - branch: details.branch, - ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), includeBranch: true, }); if (!suggestion) { return yield* gitManagerError( - "suggestCommitAndBranch", - "Cannot suggest a commit and branch because there are no changes to commit.", + "runFeatureBranchStep", + "Cannot create a feature branch because there are no changes to commit.", ); } + const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); + const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); + const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); + + yield* gitCore.createBranch({ cwd, branch: resolvedBranch }); + yield* gitCore.checkoutBranch({ cwd, branch: resolvedBranch }); + return { - commitMessage: suggestion.commitMessage, - branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject), + branchStep: { status: "created" as const, name: resolvedBranch }, + resolvedCommitMessage: suggestion.commitMessage, }; - }, - ); + }); const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( function* (input) { @@ -460,28 +465,46 @@ export const makeGitManager = Effect.gen(function* () { const wantsPr = input.action === "commit_push_pr"; const initialStatus = yield* gitCore.statusDetails(input.cwd); - if (wantsPush && !initialStatus.branch) { + if (!input.featureBranch && wantsPush && !initialStatus.branch) { return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); } - if (wantsPr && !initialStatus.branch) { + if (!input.featureBranch && wantsPr && !initialStatus.branch) { return yield* gitManagerError( "runStackedAction", "Cannot create a pull request from detached HEAD.", ); } - const commit = yield* runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); + let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; + let commitMessageForStep = input.commitMessage; + + if (input.featureBranch) { + const result = yield* runFeatureBranchStep( + input.cwd, + initialStatus.branch, + input.commitMessage, + ); + branchStep = result.branchStep; + commitMessageForStep = result.resolvedCommitMessage; + } else { + branchStep = { status: "skipped_not_requested" as const }; + } + + const currentBranch = branchStep.name ?? initialStatus.branch; + + const commit = yield* runCommitStep(input.cwd, currentBranch, commitMessageForStep); const push = wantsPush - ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) + ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch) : { status: "skipped_not_requested" as const }; const pr = wantsPr - ? yield* runPrStep(input.cwd, initialStatus.branch) + ? yield* runPrStep(input.cwd, currentBranch) : { status: "skipped_not_requested" as const }; return { action: input.action, + branch: branchStep, commit, push, pr, @@ -492,7 +515,6 @@ export const makeGitManager = Effect.gen(function* () { return { status, runStackedAction, - suggestCommitAndBranch, } satisfies GitManagerShape; }); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 399dee8f7b5c3c812a840a204f9d8999587a5fb9..c60b9bd3e903213fbfcc92ea61c253efae3b646f 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -157,6 +157,11 @@ export interface GitCoreShape { * Initialize a repository in the provided directory. */ readonly initRepo: (input: GitInitInput) => Effect.Effect; + + /** + * List local branch names (short format). + */ + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } /** diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index f71db17519514f152029f74bd3862b46e0a946ab..e3f90202dd92a047e9b466fb0a32c7b48900e8e8 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -9,8 +9,6 @@ import { GitRunStackedActionInput, GitRunStackedActionResult, - GitSuggestCommitAndBranchInput, - GitSuggestCommitAndBranchResult, GitStatusInput, GitStatusResult, } from "@t3tools/contracts"; @@ -31,17 +29,11 @@ export interface GitManagerShape { /** * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). + * When `featureBranch` is set, creates and checks out a feature branch first. */ readonly runStackedAction: ( input: GitRunStackedActionInput, ) => Effect.Effect; - - /** - * Suggest a semantic branch name and commit message for current staged changes. - */ - readonly suggestCommitAndBranch: ( - input: GitSuggestCommitAndBranchInput, - ) => Effect.Effect; } /** diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 9ed8168981a24163e15a83556f410004c032fee7..f288332f862ac512e526c2b7f05b38a2dbeb1002 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1555,8 +1555,7 @@ describe("WebSocket Server", () => { const status = vi.fn(() => Effect.succeed(statusResult)); const runStackedAction = vi.fn(() => Effect.void as any); - const suggestCommitAndBranch = vi.fn(() => Effect.void as any); - const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch }; + const gitManager: GitManagerShape = { status, runStackedAction }; server = await createTestServer({ cwd: "/test", gitManager }); const addr = server.address(); @@ -1586,7 +1585,6 @@ describe("WebSocket Server", () => { const gitManager: GitManagerShape = { status: vi.fn(() => Effect.void as any), runStackedAction, - suggestCommitAndBranch: vi.fn(() => Effect.void as any), }; server = await createTestServer({ cwd: "/test", gitManager }); @@ -1609,34 +1607,6 @@ describe("WebSocket Server", () => { }); }); - it("supports git.suggestCommitAndBranch over websocket", async () => { - const suggestCommitAndBranchResult = { - commitMessage: "feat: improve git actions", - branch: "feature/improve-git-actions", - }; - const suggestCommitAndBranch = vi.fn(() => Effect.succeed(suggestCommitAndBranchResult)); - const gitManager: GitManagerShape = { - status: vi.fn(() => Effect.void as any), - runStackedAction: vi.fn(() => Effect.void as any), - suggestCommitAndBranch, - }; - - server = await createTestServer({ cwd: "/test", gitManager }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const ws = await connectWs(port); - connections.push(ws); - await waitForMessage(ws); - - const response = await sendRequest(ws, WS_METHODS.gitSuggestCommitAndBranch, { - cwd: "/test", - }); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual(suggestCommitAndBranchResult); - expect(suggestCommitAndBranch).toHaveBeenCalledWith({ cwd: "/test" }); - }); - it("rejects websocket connections without a valid auth token", async () => { server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); const addr = server.address(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index fe30995fdb93c7490a6d44ce1389e7195baf9278..9e5c4e788670ac72ff28926a54387e832f078785 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -668,11 +668,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* gitManager.runStackedAction(body); } - case WS_METHODS.gitSuggestCommitAndBranch: { - const body = stripRequestTag(request.body); - return yield* gitManager.suggestCommitAndBranch(body); - } - case WS_METHODS.gitListBranches: { const body = stripRequestTag(request.body); return yield* git.listBranches(body); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f381cedbd5235862878ad0fc6644c80926979d47..3b8464766928d1fd30ac2f0ac0a5ec255ee05b01 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -823,6 +823,7 @@ describe("summarizeGitResult", () => { it("returns commit-focused toast for commit action", () => { const result = summarizeGitResult({ action: "commit", + branch: { status: "skipped_not_requested" }, commit: { status: "created", commitSha: "0123456789abcdef", @@ -841,6 +842,7 @@ describe("summarizeGitResult", () => { it("returns push-focused toast for push action", () => { const result = summarizeGitResult({ action: "commit_push", + branch: { status: "skipped_not_requested" }, commit: { status: "created", commitSha: "abcdef0123456789", @@ -863,6 +865,7 @@ describe("summarizeGitResult", () => { it("returns PR-focused toast for created PR action", () => { const result = summarizeGitResult({ action: "commit_push_pr", + branch: { status: "skipped_not_requested" }, commit: { status: "created", commitSha: "89abcdef01234567", @@ -888,6 +891,7 @@ describe("summarizeGitResult", () => { it("truncates long description text", () => { const result = summarizeGitResult({ action: "commit_push_pr", + branch: { status: "skipped_not_requested" }, commit: { status: "created", commitSha: "89abcdef01234567", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 53dcf982f44fbcb8856d7a0c56c41d2b38ad0823..9a9476e4cea571851055b7a80693d94c4e0a61cb 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -3,7 +3,6 @@ import type { GitStackedAction, GitStatusResult, } from "@t3tools/contracts"; -import { sanitizeBranchFragment } from "@t3tools/shared/git"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -36,7 +35,6 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; -const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; function shortenSha(sha: string | undefined): string | null { if (!sha) return null; @@ -59,7 +57,9 @@ export function buildGitActionProgressStages(input: { hasWorkingTreeChanges: boolean; forcePushOnly?: boolean; pushTarget?: string; + featureBranch?: boolean; }): string[] { + const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; const shouldIncludeCommitStages = !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); const commitStages = !shouldIncludeCommitStages @@ -69,12 +69,12 @@ export function buildGitActionProgressStages(input: { : ["Generating commit message...", "Committing..."]; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; if (input.action === "commit") { - return commitStages; + return [...branchStages, ...commitStages]; } if (input.action === "commit_push") { - return [...commitStages, pushStage]; + return [...branchStages, ...commitStages, pushStage]; } - return [...commitStages, pushStage, "Creating PR..."]; + return [...branchStages, ...commitStages, pushStage, "Creating PR..."]; } const withDescription = (title: string, description: string | undefined) => @@ -322,29 +322,5 @@ export function resolveDefaultBranchActionDialogCopy(input: { }; } -export function resolveAutoFeatureBranchName( - existingBranchNames: readonly string[], - preferredBranch?: string, -): string { - const preferred = preferredBranch?.trim(); - const normalized = sanitizeBranchFragment( - preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, - ); - const resolvedBase = normalized.includes("/") - ? normalized.startsWith("feature/") - ? normalized - : `feature/${normalized}` - : `feature/${normalized}`; - const existingNames = new Set(existingBranchNames); - - if (!existingNames.has(resolvedBase)) { - return resolvedBase; - } - - let suffix = 2; - while (existingNames.has(`${resolvedBase}-${suffix}`)) { - suffix += 1; - } - - return `${resolvedBase}-${suffix}`; -} +// Re-export from shared for backwards compatibility in this module's exports +export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 0299b12d432068ce7646d9fa4ac9520bed31e5c1..5ecae7ecfe09afe1bd13a8a2aa1ddfd6e1122002 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,4 +1,4 @@ -import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts"; +import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; @@ -11,7 +11,6 @@ import { type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, - resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, resolveQuickAction, summarizeGitResult, @@ -34,12 +33,10 @@ import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; import { gitBranchesQueryOptions, - gitCreateBranchAndCheckoutMutationOptions, gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, - gitSuggestCommitAndBranchMutationOptions, gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; @@ -60,8 +57,6 @@ interface PendingDefaultBranchAction { onConfirmed?: () => void; } -type GitActionToastId = ReturnType; - function getMenuActionDisabledReason( item: GitActionMenuItem, gitStatus: GitStatusResult | null, @@ -173,26 +168,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), ); - const suggestCommitAndBranchMutation = useMutation( - gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }), - ); - const createBranchAndCheckoutMutation = useMutation( - gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), - ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); const isRunStackedActionRunning = useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; - const isSuggestCommitAndBranchRunning = - useIsMutating({ mutationKey: gitMutationKeys.suggestCommitAndBranch(gitCwd) }) > 0; - const isCreateBranchAndCheckoutRunning = - useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; - const isGitActionRunning = - isRunStackedActionRunning || - isSuggestCommitAndBranchRunning || - isCreateBranchAndCheckoutRunning || - isPullRunning; + const isGitActionRunning = isRunStackedActionRunning || isPullRunning; const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; @@ -255,26 +236,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions forcePushOnlyProgress = false, onConfirmed, skipDefaultBranchPrompt = false, - statusOverride, - isDefaultBranchOverride, - progressToastId, + featureBranch = false, }: { action: GitStackedAction; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; - statusOverride?: GitStatusResult | null; - isDefaultBranchOverride?: boolean; - progressToastId?: GitActionToastId; + featureBranch?: boolean; }) => { - const actionStatus = statusOverride ?? gitStatusForActions; - const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch; + const actionStatus = gitStatusForActions; + const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; const actionBranch = actionStatus?.branch ?? null; const includesCommit = !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); if ( !skipDefaultBranchPrompt && + !featureBranch && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { @@ -293,36 +271,26 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } onConfirmed?.(); - const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined; + const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined; const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, + featureBranch, ...(pushTarget ? { pushTarget } : {}), }); - const resolvedProgressToastId = - progressToastId ?? - toastManager.add({ - type: "loading", - title: progressStages[0] ?? "Running git action...", - timeout: 0, - data: threadToastData, - }); - - if (progressToastId) { - toastManager.update(progressToastId, { - type: "loading", - title: progressStages[0] ?? "Running git action...", - timeout: 0, - data: threadToastData, - }); - } + const progressToastId = toastManager.add({ + type: "loading", + title: progressStages[0] ?? "Running git action...", + timeout: 0, + data: threadToastData, + }); let stageIndex = 0; const stageInterval = setInterval(() => { stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); - toastManager.update(resolvedProgressToastId, { + toastManager.update(progressToastId, { title: progressStages[stageIndex] ?? "Running git action...", type: "loading", timeout: 0, @@ -337,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), + ...(featureBranch ? { featureBranch } : {}), }); try { @@ -357,10 +326,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions result.push.status === "pushed" && !actionIsDefaultBranch; const closeResultToast = () => { - toastManager.close(resolvedProgressToastId); + toastManager.close(progressToastId); }; - toastManager.update(resolvedProgressToastId, { + toastManager.update(progressToastId, { type: "success", title: resultToast.title, description: resultToast.description, @@ -378,8 +347,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action: "commit_push", forcePushOnlyProgress: true, onConfirmed: closeResultToast, - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, }); }, }, @@ -404,8 +371,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions closeResultToast(); void runGitActionWithToast({ action: "commit_push_pr", - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, }); }, }, @@ -414,7 +379,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); } catch (err) { stopProgressUpdates(); - toastManager.update(resolvedProgressToastId, { + toastManager.update(progressToastId, { type: "error", title: "Action failed", description: err instanceof Error ? err.message : "An error occurred.", @@ -449,92 +414,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions (actionParams: { action: GitStackedAction; commitMessage?: string; - forcePushOnlyProgress?: boolean; onConfirmed?: () => void; }) => { - const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? []; - const providedCommitMessage = actionParams.commitMessage?.trim() || undefined; - const shouldSuggestCommitAndBranch = - !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges; - - const checkoutToastId = toastManager.add({ - type: "loading", - title: "Preparing feature branch...", - timeout: 0, - data: threadToastData, + void runGitActionWithToast({ + ...actionParams, + featureBranch: true, + skipDefaultBranchPrompt: true, }); - - const suggestionPromise = shouldSuggestCommitAndBranch - ? suggestCommitAndBranchMutation.mutateAsync({}).catch(() => null) - : Promise.resolve(null); - - void suggestionPromise - .then((suggestion) => { - const preferredBranch = - suggestion?.branch ?? - (providedCommitMessage ? providedCommitMessage.split(/\r?\n/g)[0]?.trim() : undefined); - const branchName = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); - const resolvedCommitMessage = suggestion?.commitMessage ?? providedCommitMessage; - - toastManager.update(checkoutToastId, { - type: "loading", - title: `Creating ${branchName}...`, - timeout: 0, - data: threadToastData, - }); - - return createBranchAndCheckoutMutation.mutateAsync(branchName).then(() => ({ - branchName, - resolvedCommitMessage, - })); - }) - .then(({ branchName, resolvedCommitMessage }) => { - const statusOverride = gitStatusForActions - ? { - ...gitStatusForActions, - branch: branchName, - pr: null, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - } - : null; - return runGitActionWithToast({ - ...actionParams, - ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}), - skipDefaultBranchPrompt: true, - statusOverride, - isDefaultBranchOverride: false, - progressToastId: checkoutToastId, - }); - }) - .catch((error) => { - toastManager.update(checkoutToastId, { - type: "error", - title: "Failed to checkout feature branch", - description: error instanceof Error ? error.message : "An error occurred.", - data: threadToastData, - }); - }); }, - [ - branchList?.branches, - createBranchAndCheckoutMutation, - gitStatusForActions, - runGitActionWithToast, - suggestCommitAndBranchMutation, - threadToastData, - ], + [runGitActionWithToast], ); const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; + const { action, commitMessage, onConfirmed } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); checkoutNewBranchAndRunAction({ action, ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), }); }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 6d2089d15ac11681ed874a3c69092a18628b7c5b..c760d345494809cfcdbf27c06f058c8aea3a15c5 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -4,7 +4,6 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, - gitSuggestCommitAndBranchMutationOptions, } from "./gitReactQuery"; describe("gitMutationKeys", () => { @@ -17,12 +16,6 @@ describe("gitMutationKeys", () => { it("scopes pull keys by cwd", () => { expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); }); - - it("scopes suggest-commit-and-branch keys by cwd", () => { - expect(gitMutationKeys.suggestCommitAndBranch("/repo/a")).not.toEqual( - gitMutationKeys.suggestCommitAndBranch("/repo/b"), - ); - }); }); describe("git mutation options", () => { @@ -37,9 +30,4 @@ describe("git mutation options", () => { const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); }); - - it("attaches cwd-scoped mutation key for suggestCommitAndBranch", () => { - const options = gitSuggestCommitAndBranchMutationOptions({ cwd: "/repo/a" }); - expect(options.mutationKey).toEqual(gitMutationKeys.suggestCommitAndBranch("/repo/a")); - }); }); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index ff3e64f70e36aae13407a341cc89f3773b7396f1..16cbe8a6cada9e0039fc320199fdbed0a4902624 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -19,8 +19,6 @@ export const gitMutationKeys = { createBranchAndCheckout: (cwd: string | null) => ["git", "mutation", "create-branch-and-checkout", cwd] as const, runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, - suggestCommitAndBranch: (cwd: string | null) => - ["git", "mutation", "suggest-commit-and-branch", cwd] as const, pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, }; @@ -118,9 +116,11 @@ export function gitRunStackedActionMutationOptions(input: { mutationFn: async ({ action, commitMessage, + featureBranch, }: { action: GitStackedAction; commitMessage?: string; + featureBranch?: boolean; }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Git action is unavailable."); @@ -128,6 +128,7 @@ export function gitRunStackedActionMutationOptions(input: { cwd: input.cwd, action, ...(commitMessage ? { commitMessage } : {}), + ...(featureBranch ? { featureBranch } : {}), }); }, onSettled: async () => { @@ -136,20 +137,6 @@ export function gitRunStackedActionMutationOptions(input: { }); } -export function gitSuggestCommitAndBranchMutationOptions(input: { cwd: string | null }) { - return mutationOptions({ - mutationKey: gitMutationKeys.suggestCommitAndBranch(input.cwd), - mutationFn: async ({ commitMessage }: { commitMessage?: string }) => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git action is unavailable."); - return api.git.suggestCommitAndBranch({ - cwd: input.cwd, - ...(commitMessage ? { commitMessage } : {}), - }); - }, - }); -} - export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { return mutationOptions({ mutationKey: gitMutationKeys.pull(input.cwd), diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 5d9c14a8c2fd86a95c79c4cc9fc61f5ef48d2735..e6bbb98959532b6120fd5d1b8184275c19f45d69 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -6,7 +6,6 @@ import { ProjectId, ThreadId, WS_CHANNELS, - WS_METHODS, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f9619f213dff4bd391f25139329f7e3782a2e5da..f3bdfe5d0718d88f823dff0c2b1d61a5f67d900f 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -163,8 +163,6 @@ export function createWsNativeApi(): NativeApi { pull: (input) => transport.request(WS_METHODS.gitPull, input), status: (input) => transport.request(WS_METHODS.gitStatus, input), runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), - suggestCommitAndBranch: (input) => - transport.request(WS_METHODS.gitSuggestCommitAndBranch, input), listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 12bd739dcf955873f37173b75656c4926e73a167..99738b7ac0d0a0071f94ba8f27b1822ca3f7fd98 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -13,6 +13,7 @@ export const GitPushStepStatus = Schema.Literals([ "skipped_not_requested", "skipped_up_to_date", ]); +export const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); export const GitPrStepStatus = Schema.Literals([ "created", "opened_existing", @@ -53,15 +54,10 @@ export const GitRunStackedActionInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), + featureBranch: Schema.optional(Schema.Boolean), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; -export const GitSuggestCommitAndBranchInput = Schema.Struct({ - cwd: TrimmedNonEmptyStringSchema, - commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), -}); -export type GitSuggestCommitAndBranchInput = typeof GitSuggestCommitAndBranchInput.Type; - export const GitListBranchesInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); @@ -145,6 +141,10 @@ export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, + branch: Schema.Struct({ + status: GitBranchStepStatus, + name: Schema.optional(TrimmedNonEmptyStringSchema), + }), commit: Schema.Struct({ status: GitCommitStepStatus, commitSha: Schema.optional(TrimmedNonEmptyStringSchema), @@ -167,12 +167,6 @@ export const GitRunStackedActionResult = Schema.Struct({ }); export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; -export const GitSuggestCommitAndBranchResult = Schema.Struct({ - commitMessage: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000)), - branch: TrimmedNonEmptyStringSchema, -}); -export type GitSuggestCommitAndBranchResult = typeof GitSuggestCommitAndBranchResult.Type; - export const GitPullResult = Schema.Struct({ status: Schema.Literals(["pulled", "skipped_up_to_date"]), branch: TrimmedNonEmptyStringSchema, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 08f1f51bf4f82ecbe7946ab23a47ed5023fba62d..aeec16ca0c32c9bee0d9b1f3c481e0917ac7587f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -11,8 +11,6 @@ import type { GitRemoveWorktreeInput, GitRunStackedActionInput, GitRunStackedActionResult, - GitSuggestCommitAndBranchInput, - GitSuggestCommitAndBranchResult, GitStatusInput, GitStatusResult, } from "./git"; @@ -90,9 +88,6 @@ export interface NativeApi { pull: (input: GitPullInput) => Promise; status: (input: GitStatusInput) => Promise; runStackedAction: (input: GitRunStackedActionInput) => Promise; - suggestCommitAndBranch: ( - input: GitSuggestCommitAndBranchInput, - ) => Promise; }; contextMenu: { show: ( diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 9830e03e8dc009da1f2390790756c14ce49bb637..7f94615bdd47cc146697ac238a5b4590627b4c4f 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -18,7 +18,6 @@ import { GitPullInput, GitRemoveWorktreeInput, GitRunStackedActionInput, - GitSuggestCommitAndBranchInput, GitStatusInput, } from "./git"; import { @@ -49,7 +48,6 @@ export const WS_METHODS = { gitPull: "git.pull", gitStatus: "git.status", gitRunStackedAction: "git.runStackedAction", - gitSuggestCommitAndBranch: "git.suggestCommitAndBranch", gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", gitRemoveWorktree: "git.removeWorktree", @@ -111,7 +109,6 @@ export const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitPull, GitPullInput), tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), - tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput), tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index fea93d85e4d40171f1a558a3eaae22f865819d16..ff4c8469d347c6dd58a81db542117d84ba5978d5 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -31,3 +31,36 @@ export function sanitizeFeatureBranchName(raw: string): string { } return `feature/${sanitized}`; } + +const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; + +/** + * Resolve a unique `feature/…` branch name that doesn't collide with + * any existing branch. Appends a numeric suffix when needed. + */ +export function resolveAutoFeatureBranchName( + existingBranchNames: readonly string[], + preferredBranch?: string, +): string { + const preferred = preferredBranch?.trim(); + const normalized = sanitizeBranchFragment( + preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, + ); + const resolvedBase = normalized.includes("/") + ? normalized.startsWith("feature/") + ? normalized + : `feature/${normalized}` + : `feature/${normalized}`; + const existingNames = new Set(existingBranchNames); + + if (!existingNames.has(resolvedBase)) { + return resolvedBase; + } + + let suffix = 2; + while (existingNames.has(`${resolvedBase}-${suffix}`)) { + suffix += 1; + } + + return `${resolvedBase}-${suffix}`; +} -- 2.54.0