diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts index 2a20c2d07..c0f093c85 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts @@ -11,10 +11,9 @@ import type { GitMenuActionId, } from "@features/git-interaction/types"; import { - sanitizeBranchName, - validateBranchName, -} from "@features/git-interaction/utils/branchNameValidation"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; + createBranch, + getBranchNameInputState, +} from "@features/git-interaction/utils/branchCreation"; import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; import { trpc, trpcClient } from "@renderer/trpc"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; @@ -480,30 +479,25 @@ export function useGitInteraction( const runBranch = async () => { if (!repoPath) return; - const branchName = store.branchName.trim(); - if (!branchName) { - modal.setBranchError("Branch name is required."); - return; - } - - const validationError = validateBranchName(branchName); - if (validationError) { - modal.setBranchError(validationError); - return; - } - modal.setIsSubmitting(true); modal.setBranchError(null); try { - await trpcClient.git.createBranch.mutate({ - directoryPath: repoPath, - branchName, + const result = await createBranch({ + repoPath, + rawBranchName: store.branchName, }); + if (!result.success) { + if (result.reason === "request") { + log.error("Failed to create branch", result.rawError ?? result.error); + trackGitAction(taskId, "branch-here", false); + } - trackGitAction(taskId, "branch-here", true); + modal.setBranchError(result.error); + return; + } - invalidateGitBranchQueries(repoPath); + trackGitAction(taskId, "branch-here", true); await queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter()); modal.closeBranch(); @@ -547,9 +541,9 @@ export function useGitInteraction( setPrTitle: modal.setPrTitle, setPrBody: modal.setPrBody, setBranchName: (value: string) => { - const sanitized = sanitizeBranchName(value); + const { sanitized, error } = getBranchNameInputState(value); modal.setBranchName(sanitized); - modal.setBranchError(validateBranchName(sanitized)); + modal.setBranchError(error); }, runCommit, runPush, diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts new file mode 100644 index 000000000..90ba900ef --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockCreateBranchMutate, mockInvalidateGitBranchQueries } = vi.hoisted( + () => ({ + mockCreateBranchMutate: vi.fn(), + mockInvalidateGitBranchQueries: vi.fn(), + }), +); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + git: { + createBranch: { + mutate: mockCreateBranchMutate, + }, + }, + }, +})); + +vi.mock("@features/git-interaction/utils/gitCacheKeys", () => ({ + invalidateGitBranchQueries: mockInvalidateGitBranchQueries, +})); + +import { createBranch, getBranchNameInputState } from "./branchCreation"; + +describe("branchCreation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getBranchNameInputState", () => { + it("sanitizes spaces and returns no error for valid names", () => { + expect(getBranchNameInputState("feature my branch")).toEqual({ + sanitized: "feature-my-branch", + error: null, + }); + }); + + it("returns validation errors for invalid names", () => { + expect(getBranchNameInputState("feature..branch")).toEqual({ + sanitized: "feature..branch", + error: 'Branch name cannot contain "..".', + }); + }); + }); + + describe("createBranch", () => { + it("returns missing-repo error when repo path is not provided", async () => { + const result = await createBranch({ + repoPath: undefined, + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "Select a repository folder first.", + reason: "missing-repo", + }); + expect(mockCreateBranchMutate).not.toHaveBeenCalled(); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("returns validation error for empty branch name", async () => { + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: " ", + }); + + expect(result).toEqual({ + success: false, + error: "Branch name is required.", + reason: "validation", + }); + expect(mockCreateBranchMutate).not.toHaveBeenCalled(); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("returns validation error for invalid branch names", async () => { + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: "feature..branch", + }); + + expect(result).toEqual({ + success: false, + error: 'Branch name cannot contain "..".', + reason: "validation", + }); + expect(mockCreateBranchMutate).not.toHaveBeenCalled(); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("creates branch with trimmed name and invalidates branch queries", async () => { + mockCreateBranchMutate.mockResolvedValueOnce(undefined); + + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: " feature/test ", + }); + + expect(mockCreateBranchMutate).toHaveBeenCalledWith({ + directoryPath: "/repo", + branchName: "feature/test", + }); + expect(mockInvalidateGitBranchQueries).toHaveBeenCalledWith("/repo"); + expect(result).toEqual({ + success: true, + branchName: "feature/test", + }); + }); + + it("returns request error with message when mutate throws Error", async () => { + const error = new Error("boom"); + mockCreateBranchMutate.mockRejectedValueOnce(error); + + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "boom", + reason: "request", + rawError: error, + }); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("returns fallback error when mutate throws non-Error value", async () => { + mockCreateBranchMutate.mockRejectedValueOnce("oops"); + + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "Failed to create branch.", + reason: "request", + rawError: "oops", + }); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts new file mode 100644 index 000000000..60b095509 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts @@ -0,0 +1,92 @@ +import { + sanitizeBranchName, + validateBranchName, +} from "@features/git-interaction/utils/branchNameValidation"; +import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; +import { trpcClient } from "@renderer/trpc"; + +export interface BranchNameInputState { + sanitized: string; + error: string | null; +} + +export type CreateBranchResult = + | { + success: true; + branchName: string; + } + | { + success: false; + error: string; + reason: "missing-repo" | "validation" | "request"; + rawError?: unknown; + }; + +interface CreateBranchInput { + repoPath?: string; + rawBranchName: string; +} + +function getCreateBranchError(error: unknown): string { + return error instanceof Error ? error.message : "Failed to create branch."; +} + +export function getBranchNameInputState(value: string): BranchNameInputState { + const sanitized = sanitizeBranchName(value); + return { + sanitized, + error: validateBranchName(sanitized), + }; +} + +export async function createBranch({ + repoPath, + rawBranchName, +}: CreateBranchInput): Promise { + if (!repoPath) { + return { + success: false, + error: "Select a repository folder first.", + reason: "missing-repo", + }; + } + + const branchName = rawBranchName.trim(); + if (!branchName) { + return { + success: false, + error: "Branch name is required.", + reason: "validation", + }; + } + + const validationError = validateBranchName(branchName); + if (validationError) { + return { + success: false, + error: validationError, + reason: "validation", + }; + } + + try { + await trpcClient.git.createBranch.mutate({ + directoryPath: repoPath, + branchName, + }); + + invalidateGitBranchQueries(repoPath); + + return { + success: true, + branchName, + }; + } catch (error) { + return { + success: false, + error: getCreateBranchError(error), + reason: "request", + rawError: error, + }; + } +} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 7cdc05fa2..630bea5b1 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -3,7 +3,13 @@ import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; import { useFolders } from "@features/folders/hooks/useFolders"; import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; +import { GitBranchDialog } from "@features/git-interaction/components/GitInteractionDialogs"; import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import { + createBranch, + getBranchNameInputState, +} from "@features/git-interaction/utils/branchCreation"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; @@ -57,6 +63,7 @@ export function TaskInput() { const [editorIsEmpty, setEditorIsEmpty] = useState(true); const [isDraggingFile, setIsDraggingFile] = useState(false); + const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [selectedBranch, setSelectedBranch] = useState(null); const [selectedEnvironment, setSelectedEnvironmentRaw] = useState< string | null @@ -94,6 +101,42 @@ export function TaskInput() { const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; + const { + branchOpen, + branchName: newBranchName, + branchError, + actions: gitActions, + } = useGitInteractionStore(); + + const handleNewBranchNameChange = useCallback( + (value: string) => { + const { sanitized, error } = getBranchNameInputState(value); + gitActions.setBranchName(sanitized); + gitActions.setBranchError(error); + }, + [gitActions], + ); + + const handleCreateBranch = useCallback(async () => { + setIsCreatingBranch(true); + + try { + const result = await createBranch({ + repoPath: selectedDirectory || undefined, + rawBranchName: newBranchName, + }); + if (!result.success) { + gitActions.setBranchError(result.error); + return; + } + + setSelectedBranch(result.branchName); + gitActions.closeBranch(); + } finally { + setIsCreatingBranch(false); + } + }, [selectedDirectory, newBranchName, gitActions]); + // Preview session provides adapter-specific config options const { modeOption, @@ -398,6 +441,18 @@ export function TaskInput() { /> + + { + if (!open) gitActions.closeBranch(); + }} + branchName={newBranchName} + onBranchNameChange={handleNewBranchNameChange} + onConfirm={handleCreateBranch} + isSubmitting={isCreatingBranch} + error={branchError} + /> ); } diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 526ae1dfe..1b26ebd60 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -13,7 +13,8 @@ type GitActionType = | "commit-push" | "create-pr" | "view-pr" - | "update-pr"; + | "update-pr" + | "branch-here"; export type FeedbackType = "good" | "bad" | "general"; type FileOpenSource = "sidebar" | "agent-suggestion" | "search" | "diff"; type FileChangeType = "added" | "modified" | "deleted";