diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 576e51b4fa4f1d0646a39079a2412f8227ed1c37..0202fc27c43929811ccec2f6dfc09bb9f603045c 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -196,6 +196,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { subject: " Add important change to the system with too much detail and a trailing period.\nsecondary line", body: "\n- added migration\n- updated tests\n", + branch: "fix/important-system-change", }), }, Effect.gen(function* () { @@ -211,6 +212,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { expect(generated.subject.length).toBeLessThanOrEqual(72); expect(generated.subject.endsWith(".")).toBe(false); expect(generated.body).toBe("- added migration\n- updated tests"); + expect(generated.branch).toBe("feature/fix/important-system-change"); }), ), ); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index d2450e7a880e3450f38c39a88decc484e0300a37..2707614e54905fb2fd6ad20913b3d7c109089ca7 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -111,6 +111,14 @@ function sanitizeBranchName(raw: string): string { return branchFragment.length > 0 ? branchFragment : "update"; } +function sanitizeFeatureBranchName(raw: string): string { + const sanitized = sanitizeBranchName(raw); + if (sanitized.includes("/")) { + return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`; + } + return `feature/${sanitized}`; +} + const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -330,10 +338,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { const prompt = [ "You write concise git commit messages.", - "Return a JSON object with keys: subject, body.", + "Return a JSON object with keys: subject, body, branch.", "Rules:", "- subject must be imperative, <= 72 chars, and no trailing period", "- body can be empty string or short bullet points", + "- branch must be a short semantic git branch fragment for this change", "- capture the primary user-visible or developer-visible change", "", `Branch: ${input.branch ?? "(detached)"}`, @@ -352,6 +361,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson: Schema.Struct({ subject: Schema.String, body: Schema.String, + branch: Schema.String, }), }).pipe( Effect.map( @@ -359,6 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { ({ subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), + branch: sanitizeFeatureBranchName(generated.branch), }) satisfies CommitMessageGenerationResult, ), ); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 33a820ac3fb23ab636bc7cea9ab4d4442b88d72b..cc05c6563c38b020a71b8c8818833c0541594d0e 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -32,7 +32,7 @@ interface FakeGitTextGeneration { branch: string | null; stagedSummary: string; stagedPatch: string; - }) => Effect.Effect<{ subject: string; body: string }, TextGenerationError>; + }) => Effect.Effect<{ subject: string; body: string; branch: string }, TextGenerationError>; generatePrContent: (input: { cwd: string; baseBranch: string; @@ -108,6 +108,7 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ subject: "Implement stacked git actions", body: "", + branch: "feature/implement-stacked-git-actions", }), generatePrContent: () => Effect.succeed({ @@ -288,6 +289,16 @@ function runStackedAction( return manager.runStackedAction(input); } +function suggestCommitAndBranch( + manager: GitManagerShape, + input: { + cwd: string; + commitMessage?: string; + }, +) { + return manager.suggestCommitAndBranch(input); +} + function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; @@ -501,6 +512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { return { subject: "this should not be used", body: "", + branch: "feature/unused", }; }), }, @@ -527,6 +539,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("suggests commit message and semantic branch for dirty worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nsuggest\n"); + + const { manager } = yield* makeManager(); + const suggestion = yield* suggestCommitAndBranch(manager, { + cwd: repoDir, + }); + + expect(suggestion).toEqual({ + commitMessage: "Implement stacked git actions", + branch: "feature/implement-stacked-git-actions", + }); + }), + ); + + it.effect("suggests branch from custom commit message without AI generation", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-suggest\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "unused", + body: "", + branch: "feature/unused", + }; + }), + }, + }); + const suggestion = yield* suggestCommitAndBranch(manager, { + cwd: repoDir, + 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(generatedCount).toBe(0); + }), + ); + it.effect("skips commit when there are no uncommitted changes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -544,6 +607,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("returns a clear error when suggesting from clean worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager(); + const errorMessage = yield* suggestCommitAndBranch(manager, { + cwd: repoDir, + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("no changes to commit"); + }), + ); + it.effect("commits and pushes with upstream auto-setup when needed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 27ad13af2767b81e1796c0a34030130c6b59e850..757aafc117836ef1228c74c263700f917b264680 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -85,9 +85,10 @@ function limitContext(value: string, maxChars: number): string { return `${value.slice(0, maxChars)}\n\n[truncated]`; } -function sanitizeCommitMessage(generated: { subject: string; body: string }): { +function sanitizeCommitMessage(generated: { subject: string; body: string; branch: string }): { subject: string; body: string; + branch: string; } { const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; const subject = rawSubject.replace(/[.]+$/g, "").trim(); @@ -95,9 +96,44 @@ function sanitizeCommitMessage(generated: { subject: string; body: string }): { return { subject: safeSubject, body: generated.body.trim(), + branch: generated.branch, }; } +function sanitizeBranchFragment(raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/['"`]/g, "") + .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); + + const branchFragment = normalized + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + return branchFragment.length > 0 ? branchFragment : "update"; +} + +function sanitizeFeatureBranchName(raw: string): string { + const sanitized = sanitizeBranchFragment(raw); + if (sanitized.includes("/")) { + return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`; + } + return `feature/${sanitized}`; +} + +function formatCommitMessage(subject: string, body: string): string { + const trimmedBody = body.trim(); + if (trimmedBody.length === 0) { + return subject; + } + return `${subject}\n\n${trimmedBody}`; +} + function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { const normalized = raw.replace(/\r\n/g, "\n").trim(); if (normalized.length === 0) { @@ -253,30 +289,62 @@ export const makeGitManager = Effect.gen(function* () { return "main"; }); - const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) => + const resolveCommitAndBranchSuggestion = (input: { + cwd: string; + branch: string | null; + commitMessage?: string; + }) => Effect.gen(function* () { - const context = yield* gitCore.prepareCommitContext(cwd); + const context = yield* gitCore.prepareCommitContext(input.cwd); if (!context) { + return null; + } + + const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); + if (customCommit) { + const branch = sanitizeFeatureBranchName(customCommit.subject); + return { + subject: customCommit.subject, + body: customCommit.body, + branch, + commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), + }; + } + + const generated = yield* textGeneration + .generateCommitMessage({ + cwd: input.cwd, + branch: input.branch, + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), + }) + .pipe(Effect.map((result) => sanitizeCommitMessage(result))); + + const branch = sanitizeFeatureBranchName(generated.branch); + return { + subject: generated.subject, + body: generated.body, + branch, + commitMessage: formatCommitMessage(generated.subject, generated.body), + }; + }); + + const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) => + Effect.gen(function* () { + const suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + }); + if (!suggestion) { return { status: "skipped_no_changes" as const }; } - let generated = parseCustomCommitMessage(commitMessage ?? ""); - if (!generated) { - generated = yield* textGeneration - .generateCommitMessage({ - cwd, - branch, - stagedSummary: limitContext(context.stagedSummary, 8_000), - stagedPatch: limitContext(context.stagedPatch, 50_000), - }) - .pipe(Effect.map((result) => sanitizeCommitMessage(result))); - } - - const { commitSha } = yield* gitCore.commit(cwd, generated.subject, generated.body); + const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body); return { status: "created" as const, commitSha, - subject: generated.subject, + subject: suggestion.subject, }; }); @@ -381,6 +449,28 @@ export const makeGitManager = Effect.gen(function* () { }; }); + const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced( + function* (input) { + const details = yield* gitCore.statusDetails(input.cwd); + const suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd: input.cwd, + branch: details.branch, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + }); + if (!suggestion) { + return yield* gitManagerError( + "suggestCommitAndBranch", + "Cannot suggest a commit and branch because there are no changes to commit.", + ); + } + + return { + commitMessage: suggestion.commitMessage, + branch: suggestion.branch, + }; + }, + ); + const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( function* (input) { const wantsPush = input.action !== "commit"; @@ -419,6 +509,7 @@ export const makeGitManager = Effect.gen(function* () { return { status, runStackedAction, + suggestCommitAndBranch, } satisfies GitManagerShape; }); diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 7293b3a5294c0056c3a11c223274364eba20cef6..f71db17519514f152029f74bd3862b46e0a946ab 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -9,6 +9,8 @@ import { GitRunStackedActionInput, GitRunStackedActionResult, + GitSuggestCommitAndBranchInput, + GitSuggestCommitAndBranchResult, GitStatusInput, GitStatusResult, } from "@t3tools/contracts"; @@ -33,6 +35,13 @@ export interface GitManagerShape { 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/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 682cb6667e92549c7b6091cce7bead6b41d9aa1e..b13114ddfb59619c4aa23f517375a1e8e110b997 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -22,6 +22,7 @@ export interface CommitMessageGenerationInput { export interface CommitMessageGenerationResult { subject: string; body: string; + branch: string; } export interface PrContentGenerationInput { diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f288332f862ac512e526c2b7f05b38a2dbeb1002..9ed8168981a24163e15a83556f410004c032fee7 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1555,7 +1555,8 @@ describe("WebSocket Server", () => { const status = vi.fn(() => Effect.succeed(statusResult)); const runStackedAction = vi.fn(() => Effect.void as any); - const gitManager: GitManagerShape = { status, runStackedAction }; + const suggestCommitAndBranch = vi.fn(() => Effect.void as any); + const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch }; server = await createTestServer({ cwd: "/test", gitManager }); const addr = server.address(); @@ -1585,6 +1586,7 @@ 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 }); @@ -1607,6 +1609,34 @@ 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 9e5c4e788670ac72ff28926a54387e832f078785..fe30995fdb93c7490a6d44ce1389e7195baf9278 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -668,6 +668,11 @@ 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 011733413e87b365576482cb08b27b03a2e26329..f381cedbd5235862878ad0fc6644c80926979d47 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -910,18 +910,26 @@ describe("summarizeGitResult", () => { }); describe("resolveAutoFeatureBranchName", () => { - const day = new Date("2026-03-01T12:00:00.000Z"); + it("uses semantic preferred branch names when available", () => { + const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); + assert.equal(branch, "feature/fix-toast-copy"); + }); - it("returns the base auto feature branch name when available", () => { - const branch = resolveAutoFeatureBranchName(["main", "feature/other"], day); - assert.equal(branch, "feature/stacked-20260301"); + it("normalizes preferred names that already include a branch namespace", () => { + const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); + assert.equal(branch, "feature/refine-toolbar-actions"); }); - it("increments suffix when the base name already exists", () => { + it("increments suffix when the preferred branch name already exists", () => { const branch = resolveAutoFeatureBranchName( - ["main", "feature/stacked-20260301", "feature/stacked-20260301-2"], - day, + ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], + "fix toast copy", ); - assert.equal(branch, "feature/stacked-20260301-3"); + assert.equal(branch, "feature/fix-toast-copy-3"); + }); + + it("falls back to feature/update when no preferred name is provided", () => { + const branch = resolveAutoFeatureBranchName(["main"]); + assert.equal(branch, "feature/update"); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index d1c91ba9424a251609a43b953ec9ce22f2564f2f..23cd63e6284fe0975ae44ad913609379689fe4b5 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -35,7 +35,7 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; -const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked"; +const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; function shortenSha(sha: string | undefined): string | null { if (!sha) return null; @@ -321,29 +321,47 @@ export function resolveDefaultBranchActionDialogCopy(input: { }; } -function formatDateForBranch(now: Date): string { - const year = now.getUTCFullYear(); - const month = String(now.getUTCMonth() + 1).padStart(2, "0"); - const day = String(now.getUTCDate()).padStart(2, "0"); - return `${year}${month}${day}`; +function sanitizeBranchFragment(raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/['"`]/g, "") + .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); + + const branchFragment = normalized + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + return branchFragment.length > 0 ? branchFragment : "update"; } export function resolveAutoFeatureBranchName( existingBranchNames: readonly string[], - now = new Date(), + preferredBranch?: string, ): string { - const daySlug = formatDateForBranch(now); - const base = `${AUTO_FEATURE_BRANCH_PREFIX}-${daySlug}`; + 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(base)) { - return base; + if (!existingNames.has(resolvedBase)) { + return resolvedBase; } let suffix = 2; - while (existingNames.has(`${base}-${suffix}`)) { + while (existingNames.has(`${resolvedBase}-${suffix}`)) { suffix += 1; } - return `${base}-${suffix}`; + return `${resolvedBase}-${suffix}`; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index a60680118621d8041eda2d3c34bfebf215a03caa..0299b12d432068ce7646d9fa4ac9520bed31e5c1 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -39,6 +39,7 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, + gitSuggestCommitAndBranchMutationOptions, gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; @@ -172,6 +173,9 @@ 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 }), ); @@ -179,11 +183,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions 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 || isCreateBranchAndCheckoutRunning || isPullRunning; + isRunStackedActionRunning || + isSuggestCommitAndBranchRunning || + isCreateBranchAndCheckoutRunning || + isPullRunning; const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; @@ -443,20 +452,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions forcePushOnlyProgress?: boolean; onConfirmed?: () => void; }) => { - const branchName = resolveAutoFeatureBranchName( - branchList?.branches.map((branch) => branch.name) ?? [], - ); + const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? []; + const providedCommitMessage = actionParams.commitMessage?.trim() || undefined; + const shouldSuggestCommitAndBranch = + !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges; - const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName); const checkoutToastId = toastManager.add({ type: "loading", - title: `Creating ${branchName}...`, + title: "Preparing feature branch...", timeout: 0, data: threadToastData, }); - void checkoutPromise - .then(() => { + 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, @@ -469,6 +501,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions : null; return runGitActionWithToast({ ...actionParams, + ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}), skipDefaultBranchPrompt: true, statusOverride, isDefaultBranchOverride: false, @@ -489,6 +522,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions createBranchAndCheckoutMutation, gitStatusForActions, runGitActionWithToast, + suggestCommitAndBranchMutation, threadToastData, ], ); diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index c760d345494809cfcdbf27c06f058c8aea3a15c5..6d2089d15ac11681ed874a3c69092a18628b7c5b 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -4,6 +4,7 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, + gitSuggestCommitAndBranchMutationOptions, } from "./gitReactQuery"; describe("gitMutationKeys", () => { @@ -16,6 +17,12 @@ 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", () => { @@ -30,4 +37,9 @@ 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 f7792177204295b668de3489d873ed49d62e9b42..ff3e64f70e36aae13407a341cc89f3773b7396f1 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -19,6 +19,8 @@ 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, }; @@ -134,6 +136,20 @@ 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 1dd19c4c6fa000a8bebaa43a74562f99e641248d..5d9c14a8c2fd86a95c79c4cc9fc61f5ef48d2735 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -6,6 +6,7 @@ import { ProjectId, ThreadId, WS_CHANNELS, + WS_METHODS, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -376,5 +377,4 @@ describe("wsNativeApi", () => { { x: 20, y: 30 }, ); }); - }); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f3bdfe5d0718d88f823dff0c2b1d61a5f67d900f..f9619f213dff4bd391f25139329f7e3782a2e5da 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -163,6 +163,8 @@ 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 e0441366ef5aaae7ed893f26f7c5b14a4e97eda2..12bd739dcf955873f37173b75656c4926e73a167 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -56,6 +56,12 @@ export const GitRunStackedActionInput = Schema.Struct({ }); 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, }); @@ -161,6 +167,12 @@ 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 aeec16ca0c32c9bee0d9b1f3c481e0917ac7587f..08f1f51bf4f82ecbe7946ab23a47ed5023fba62d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -11,6 +11,8 @@ import type { GitRemoveWorktreeInput, GitRunStackedActionInput, GitRunStackedActionResult, + GitSuggestCommitAndBranchInput, + GitSuggestCommitAndBranchResult, GitStatusInput, GitStatusResult, } from "./git"; @@ -88,6 +90,9 @@ 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 7f94615bdd47cc146697ac238a5b4590627b4c4f..9830e03e8dc009da1f2390790756c14ce49bb637 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -18,6 +18,7 @@ import { GitPullInput, GitRemoveWorktreeInput, GitRunStackedActionInput, + GitSuggestCommitAndBranchInput, GitStatusInput, } from "./git"; import { @@ -48,6 +49,7 @@ 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", @@ -109,6 +111,7 @@ 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),