diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index dc0307ba224..5d75fd2248f 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -1,5 +1,6 @@ -import { existsSync } from "node:fs"; -import { join, resolve, sep } from "node:path"; +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; @@ -84,12 +85,25 @@ function projectNotSetupError(projectId: string): TRPCError { }); } -function safeResolveWorktreePath(repoPath: string, branchName: string): string { - const worktreesRoot = resolve(repoPath, ".worktrees"); - const worktreePath = resolve(worktreesRoot, branchName); +// Kept outside the primary checkout so editors, file watchers, and +// ignore rules treat worktrees as separate trees, not nested ones. +function supersetWorktreesRoot(): string { + return join(homedir(), ".superset", "worktrees"); +} + +function projectWorktreesRoot(projectId: string): string { + return resolve(supersetWorktreesRoot(), projectId); +} + +function safeResolveWorktreePath( + projectId: string, + branchName: string, +): string { + const projectRoot = projectWorktreesRoot(projectId); + const worktreePath = resolve(projectRoot, branchName); if ( - worktreePath !== worktreesRoot && - !worktreePath.startsWith(worktreesRoot + sep) + worktreePath !== projectRoot && + !worktreePath.startsWith(projectRoot + sep) ) { throw new TRPCError({ code: "BAD_REQUEST", @@ -168,18 +182,29 @@ function markRefetchRemote(projectId: string): void { type GitClient = Awaited>; async function listWorktreeBranches( + ctx: HostServiceContext, git: GitClient, - repoPath: string, + projectId: string, ): Promise<{ - // Superset-managed worktrees only (under /.worktrees/). - // These count as "has a workspace" for the picker. + // A worktree counts as "ours" if its path either matches a row in + // the local `workspaces` table or lives under our managed root. The + // second case catches orphans (worktree on disk, no workspaces row, + // e.g. partial create rollback) so they surface for adoption. worktreeMap: Map; // Every branch checked out in any git worktree, including the primary // working tree. Used to disable the Checkout action when a branch is // already in use elsewhere — `git worktree add ` would fail. checkedOutBranches: Set; }> { - const worktreesRoot = resolve(repoPath, ".worktrees"); + const managedRoot = projectWorktreesRoot(projectId); + const knownPaths = new Set( + ctx.db + .select({ path: workspaces.worktreePath }) + .from(workspaces) + .where(eq(workspaces.projectId, projectId)) + .all() + .map((w) => w.path), + ); const worktreeMap = new Map(); const checkedOutBranches = new Set(); try { @@ -192,9 +217,10 @@ async function listWorktreeBranches( const branch = line.slice("branch refs/heads/".length).trim(); if (!branch) continue; checkedOutBranches.add(branch); - // Superset-managed worktrees live under /.worktrees/; - // the primary working tree is at repoPath itself and skipped here. - if (currentPath.startsWith(worktreesRoot + sep)) { + if ( + knownPaths.has(currentPath) || + currentPath.startsWith(managedRoot + sep) + ) { worktreeMap.set(branch, currentPath); } } else if (line === "") { @@ -558,8 +584,9 @@ export const workspaceCreationRouter = router({ const defaultBranch: string | null = await resolveDefaultBranchName(git); const { worktreeMap, checkedOutBranches } = await listWorktreeBranches( + ctx, git, - localProject.repoPath, + input.projectId, ); const recencyMap = await getRecentBranchOrder(git, 30); @@ -793,10 +820,8 @@ export const workspaceCreationRouter = router({ ); // 3. Create worktree - const worktreePath = safeResolveWorktreePath( - localProject.repoPath, - branchName, - ); + const worktreePath = safeResolveWorktreePath(localProject.id, branchName); + mkdirSync(dirname(worktreePath), { recursive: true }); const git = await ctx.git(localProject.repoPath); @@ -1166,11 +1191,12 @@ export const workspaceCreationRouter = router({ let worktreePath: string; try { - worktreePath = safeResolveWorktreePath(localProject.repoPath, branch); + worktreePath = safeResolveWorktreePath(localProject.id, branch); } catch (err) { clearProgress(input.pendingId); throw err; } + mkdirSync(dirname(worktreePath), { recursive: true }); const git = await ctx.git(localProject.repoPath); // Detect a pre-existing local branch with the same derived name @@ -1291,11 +1317,12 @@ export const workspaceCreationRouter = router({ let worktreePath: string; try { - worktreePath = safeResolveWorktreePath(localProject.repoPath, branch); + worktreePath = safeResolveWorktreePath(localProject.id, branch); } catch (err) { clearProgress(input.pendingId); throw err; } + mkdirSync(dirname(worktreePath), { recursive: true }); const git = await ctx.git(localProject.repoPath); // Resolve via the discriminated-ref helper so we don't infer kind @@ -1394,10 +1421,10 @@ export const workspaceCreationRouter = router({ /** * Adopt an existing git worktree as a workspace. Used when the Worktree - * tab surfaces a branch whose `.worktrees/` directory exists on - * disk but has no corresponding workspaces row (e.g. created by an older - * flow, or partial create rollback). No git ops — just registers the - * cloud + local workspace row over the existing worktree path. + * tab surfaces a branch whose worktree directory exists on disk but has + * no corresponding workspaces row (e.g. partial create rollback). No git + * ops — just registers the cloud + local workspace row over the + * existing worktree path. */ adopt: protectedProcedure .input( @@ -1446,8 +1473,9 @@ export const workspaceCreationRouter = router({ worktreePath = input.worktreePath; } else { const { worktreeMap } = await listWorktreeBranches( + ctx, git, - localProject.repoPath, + input.projectId, ); const found = worktreeMap.get(branch); if (!found) {