Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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<CreateBranchResult> {
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,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
const [selectedEnvironment, setSelectedEnvironmentRaw] = useState<
string | null
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -398,6 +441,18 @@ export function TaskInput() {
/>
</Flex>
</Flex>

<GitBranchDialog
open={branchOpen}
onOpenChange={(open) => {
if (!open) gitActions.closeBranch();
}}
branchName={newBranchName}
onBranchNameChange={handleNewBranchNameChange}
onConfirm={handleCreateBranch}
isSubmitting={isCreatingBranch}
error={branchError}
/>
</div>
);
}
Loading