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
67 changes: 52 additions & 15 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import {
openExternalWorktree,
} from "../utils/workspace-creation";
import { initializeWorkspaceWorktree } from "../utils/workspace-init";
import {
throwWorkspaceCreateDomainError,
validateExistingBranchCreate,
} from "./utils/create-domain-errors";

function getPrWorkspaceName(prInfo: PullRequestInfo): string {
return prInfo.title || `PR #${prInfo.number}`;
Expand Down Expand Up @@ -269,6 +273,9 @@ export const createCreateProcedures = () => {
compareBaseBranch: z.string().optional(),
sourceWorkspaceId: z.string().optional(),
useExistingBranch: z.boolean().optional(),
intent: z
.enum(["create_from_existing_branch", "create_new_branch"])
.optional(),
applyPrefix: z.boolean().optional().default(true),
})
.refine(
Expand Down Expand Up @@ -319,6 +326,22 @@ export const createCreateProcedures = () => {
}

let existingBranchName: string | undefined;
let existingBranches: string[];
try {
const { local, remote } = await listBranches(project.mainRepoPath);
existingBranches = [...local, ...remote];
} catch (error) {
console.error("[workspaces/create] Failed to list branches", {
projectId: project.id,
error: error instanceof Error ? error.message : String(error),
});
throwWorkspaceCreateDomainError(
"GIT_OPERATION_FAILED",
"Unable to list branches. Please try again.",
"INTERNAL_SERVER_ERROR",
);
}

if (input.useExistingBranch) {
existingBranchName = input.branchName?.trim();
if (!existingBranchName) {
Expand All @@ -327,20 +350,34 @@ export const createCreateProcedures = () => {
);
}

const existingWorktreePath = await getBranchWorktreePath({
mainRepoPath: project.mainRepoPath,
branch: existingBranchName,
});
if (existingWorktreePath) {
throw new Error(
`Branch "${existingBranchName}" is already checked out in another worktree at: ${existingWorktreePath}`,
let existingWorktreePath: string | null;
try {
existingWorktreePath = await getBranchWorktreePath({
mainRepoPath: project.mainRepoPath,
branch: existingBranchName,
});
} catch (error) {
console.error(
"[workspaces/create] Failed to resolve existing branch worktree path",
{
projectId: project.id,
branch: existingBranchName,
error: error instanceof Error ? error.message : String(error),
},
);
throwWorkspaceCreateDomainError(
"GIT_OPERATION_FAILED",
"Failed to verify existing worktrees for the selected branch.",
"INTERNAL_SERVER_ERROR",
);
}
validateExistingBranchCreate({
branchName: existingBranchName,
existingBranches,
existingWorktreePath,
});
}

const { local, remote } = await listBranches(project.mainRepoPath);
const existingBranches = [...local, ...remote];

// Resolve branch prefix using shared utility
let branchPrefix: string | undefined;
if (input.applyPrefix) {
Expand All @@ -360,11 +397,6 @@ export const createCreateProcedures = () => {

let branch: string;
if (existingBranchName) {
if (!existingBranches.includes(existingBranchName)) {
throw new Error(
`Branch "${existingBranchName}" does not exist. Please select an existing branch.`,
);
}
branch = existingBranchName;
} else if (input.branchName?.trim()) {
branch = sanitizeBranchNameWithMaxLength(
Expand Down Expand Up @@ -502,6 +534,11 @@ export const createCreateProcedures = () => {
branch: branch,
base_branch: compareBaseBranch,
use_existing_branch: input.useExistingBranch ?? false,
intent:
input.intent ??
(input.useExistingBranch
? "create_from_existing_branch"
: "create_new_branch"),
});

await setBranchBaseConfig({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { TRPCError } from "@trpc/server";
import {
throwWorkspaceCreateDomainError,
validateExistingBranchCreate,
} from "./create-domain-errors";

describe("create-domain-errors", () => {
test("throws BRANCH_NOT_FOUND when branch is missing", () => {
expect(() =>
validateExistingBranchCreate({
branchName: "feature/missing",
existingBranches: ["main", "develop"],
existingWorktreePath: null,
}),
).toThrow('BRANCH_NOT_FOUND: Branch "feature/missing" no longer exists.');
});

test("throws WORKTREE_ALREADY_EXISTS_FOR_BRANCH when worktree exists", () => {
expect(() =>
validateExistingBranchCreate({
branchName: "feature/existing",
existingBranches: ["main", "feature/existing"],
existingWorktreePath: "/tmp/worktrees/feature-existing",
}),
).toThrow(
'WORKTREE_ALREADY_EXISTS_FOR_BRANCH: Branch "feature/existing" is already checked out in another worktree.',
);
});

test("passes when branch exists and no worktree exists", () => {
expect(() =>
validateExistingBranchCreate({
branchName: "feature/new",
existingBranches: ["main", "feature/new"],
existingWorktreePath: null,
}),
).not.toThrow();
});

test("throws with provided tRPC code", () => {
try {
throwWorkspaceCreateDomainError(
"GIT_OPERATION_FAILED",
"Unable to list branches.",
"INTERNAL_SERVER_ERROR",
);
throw new Error("Expected TRPCError");
} catch (error) {
expect(error).toBeInstanceOf(TRPCError);
expect((error as TRPCError).code).toBe("INTERNAL_SERVER_ERROR");
expect((error as TRPCError).message).toBe(
"GIT_OPERATION_FAILED: Unable to list branches.",
);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { TRPCError } from "@trpc/server";

export type WorkspaceCreateDomainErrorCode =
| "WORKTREE_ALREADY_EXISTS_FOR_BRANCH"
| "BRANCH_NOT_FOUND"
| "GIT_OPERATION_FAILED";

export function throwWorkspaceCreateDomainError(
code: WorkspaceCreateDomainErrorCode,
message: string,
trpcCode: TRPCError["code"] = "BAD_REQUEST",
): never {
console.warn("[workspaces/create] Domain error", { code, message });
throw new TRPCError({
code: trpcCode,
message: `${code}: ${message}`,
});
}

export function validateExistingBranchCreate({
branchName,
existingBranches,
existingWorktreePath,
}: {
branchName: string;
existingBranches: string[];
existingWorktreePath: string | null;
}): void {
if (!existingBranches.includes(branchName)) {
throwWorkspaceCreateDomainError(
"BRANCH_NOT_FOUND",
`Branch "${branchName}" no longer exists.`,
);
}

if (existingWorktreePath) {
throwWorkspaceCreateDomainError(
"WORKTREE_ALREADY_EXISTS_FOR_BRANCH",
`Branch "${branchName}" is already checked out in another worktree.`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import path from "node:path";
import { getDefaultWorktreeBaseDir } from "./default-worktree-base-dir";

describe("getDefaultWorktreeBaseDir", () => {
test("uses stable ~/.superset/worktrees default base dir", () => {
expect(getDefaultWorktreeBaseDir("/Users/tester")).toBe(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
path.join("/Users/tester", ".superset", "worktrees"),
);
});

test("does not include workspace-name-scoped suffix", () => {
const baseDir = getDefaultWorktreeBaseDir("/Users/tester");
expect(baseDir.includes(".superset-open-workspace")).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { homedir } from "node:os";
import { join } from "node:path";
import {
PROJECT_SUPERSET_DIR_NAME,
WORKTREES_DIR_NAME,
} from "shared/constants";

export function getDefaultWorktreeBaseDir(homeDirectory = homedir()): string {
return join(homeDirectory, PROJECT_SUPERSET_DIR_NAME, WORKTREES_DIR_NAME);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { type SelectProject, settings } from "@superset/local-db";
import { localDb } from "main/lib/local-db";
import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants";
import { getDefaultWorktreeBaseDir } from "./default-worktree-base-dir";

/** Resolves base dir: project override > global setting > default (~/.superset/worktrees) */
export function resolveWorktreePath(
Expand All @@ -14,9 +13,7 @@ export function resolveWorktreePath(
}

const row = localDb.select().from(settings).get();
const baseDir =
row?.worktreeBaseDir ??
join(homedir(), SUPERSET_DIR_NAME, WORKTREES_DIR_NAME);
const baseDir = row?.worktreeBaseDir ?? getDefaultWorktreeBaseDir();

return join(baseDir, project.name, branch);
}
Loading