Skip to content
Draft
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
88 changes: 35 additions & 53 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { randomUUID } from "node:crypto";
import { projects, workspaces, worktrees } from "@superset/local-db";
import { and, eq, isNull, not } from "drizzle-orm";
import { track } from "main/lib/analytics";
import { localDb } from "main/lib/local-db";
import { workspaceInitManager } from "main/lib/workspace-init-manager";
import {
type DraftWorkspaceProvisioningJob,
workspaceInitManager,
} from "main/lib/workspace-init-manager";
import { z } from "zod";
import { publicProcedure, router } from "../../..";
import { attemptWorkspaceAutoRenameFromPrompt } from "../utils/ai-name";
Expand All @@ -21,6 +25,7 @@ import {
setLastActiveWorkspace,
touchWorkspace,
} from "../utils/db-helpers";
import { buildDraftWorkspaceRow } from "../utils/draft-workspace";
import {
createWorktreeFromPr,
generateBranchName,
Expand All @@ -29,7 +34,7 @@ import {
getPrInfo,
getPrLocalBranchName,
listBranches,
listExternalWorktrees,
listGitWorktrees,
type PullRequestInfo,
parsePrUrl,
safeCheckoutBranch,
Expand Down Expand Up @@ -463,65 +468,44 @@ export const createCreateProcedures = () => {
defaultBranch: project.defaultBranch,
knownBranches: existingBranches,
});

const worktree = localDb
.insert(worktrees)
.values({
projectId: input.projectId,
path: worktreePath,
branch,
baseBranch: compareBaseBranch,
gitStatus: null,
createdBySuperset: true,
})
.returning()
.get();

const maxTabOrder = getMaxProjectChildTabOrder(input.projectId);

const workspace = localDb
.insert(workspaces)
.values({
projectId: input.projectId,
worktreeId: worktree.id,
type: "worktree",
branch,
name: input.name ?? branch,
isUnnamed: !input.name,
tabOrder: maxTabOrder + 1,
})
.returning()
.get();

setLastActiveWorkspace(workspace.id);
activateProject(project);

track("workspace_created", {
workspace_id: workspace.id,
project_id: project.id,
branch: branch,
base_branch: compareBaseBranch,
use_existing_branch: input.useExistingBranch ?? false,
});

await setBranchBaseConfig({
repoPath: project.mainRepoPath,
const draftJob: DraftWorkspaceProvisioningJob = {
workspaceId: randomUUID(),
worktreeId: randomUUID(),
projectId: input.projectId,
branch,
workspaceName: input.name ?? branch,
isUnnamed: !input.name,
worktreePath,
mainRepoPath: project.mainRepoPath,
compareBaseBranch,
isExplicit: Boolean(requestedCompareBaseBranch?.trim()),
});
compareBaseBranchIsExplicit: Boolean(
requestedCompareBaseBranch?.trim(),
),
startPointBranch: sourceWorkspace?.branch,
namingPrompt: input.prompt,
useExistingBranch: input.useExistingBranch,
startedAt: Date.now(),
};

const workspace = buildDraftWorkspaceRow(draftJob);

workspaceInitManager.startJob(workspace.id, input.projectId);
workspaceInitManager.startJob(
draftJob.workspaceId,
input.projectId,
draftJob,
);
initializeWorkspaceWorktree({
workspaceId: workspace.id,
workspaceId: draftJob.workspaceId,
projectId: input.projectId,
worktreeId: worktree.id,
worktreeId: draftJob.worktreeId,
worktreePath,
branch,
mainRepoPath: project.mainRepoPath,
startPointBranch: sourceWorkspace?.branch,
namingPrompt: input.prompt,
useExistingBranch: input.useExistingBranch,
draftJob,
});

const setupConfig = loadSetupConfig({
Expand Down Expand Up @@ -864,12 +848,10 @@ export const createCreateProcedures = () => {
}

// 2. Import external worktrees (on disk, not tracked in DB)
const allExternalWorktrees = await listExternalWorktrees(
project.mainRepoPath,
);
const allGitWorktrees = await listGitWorktrees(project.mainRepoPath);
const trackedPaths = new Set(projectWorktrees.map((wt) => wt.path));

const externalWorktrees = allExternalWorktrees.filter((wt) => {
const externalWorktrees = allGitWorktrees.filter((wt) => {
if (wt.path === project.mainRepoPath) return false;
if (wt.isBare) return false;
if (wt.isDetached) return false;
Expand Down
200 changes: 91 additions & 109 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import type { SelectWorktree } from "@superset/local-db";
import { track } from "main/lib/analytics";
import { workspaceInitManager } from "main/lib/workspace-init-manager";
Expand All @@ -21,24 +20,10 @@ import {
deleteLocalBranch,
hasUncommittedChanges,
hasUnpushedCommits,
listExternalWorktrees,
worktreeExists,
} from "../utils/git";
import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown";

/**
* Normalize a filesystem path for comparison.
* Uses realpathSync to resolve symlinks and get canonical path.
* Falls back to resolve if realpathSync fails (e.g., path doesn't exist).
*/
const normalizePath = (p: string): string => {
try {
return realpathSync(p);
} catch {
return resolve(p);
}
};

export const createDeleteProcedures = () => {
return router({
canDelete: publicProcedure
Expand All @@ -50,6 +35,41 @@ export const createDeleteProcedures = () => {
)
.query(async ({ input }) => {
const workspace = getWorkspace(input.id);
const draftJob = workspace
? null
: workspaceInitManager.getDraftJob(input.id);

if (!workspace && draftJob) {
const progress = workspaceInitManager.getProgress(input.id);
return {
canDelete: true,
reason: null,
workspace: {
id: draftJob.workspaceId,
projectId: draftJob.projectId,
worktreeId: draftJob.worktreeId,
type: "worktree" as const,
branch: draftJob.branch,
name: draftJob.workspaceName,
tabOrder: Number.MAX_SAFE_INTEGER,
createdAt: draftJob.startedAt,
updatedAt: draftJob.startedAt,
lastOpenedAt: draftJob.startedAt,
isUnread: false,
isUnnamed: draftJob.isUnnamed,
deletingAt: null,
portBase: null,
sectionId: null,
},
warning:
progress?.step === "failed"
? "Workspace provisioning failed before it was created"
: "Workspace is still provisioning",
activeTerminalCount: 0,
hasChanges: false,
hasUnpushedCommits: false,
};
}

if (!workspace) {
return {
Expand Down Expand Up @@ -173,6 +193,25 @@ export const createDeleteProcedures = () => {
)
.mutation(async ({ input }) => {
const workspace = getWorkspace(input.id);
const draftJob = workspace
? null
: workspaceInitManager.getDraftJob(input.id);

if (!workspace && draftJob) {
if (workspaceInitManager.isInitializing(input.id)) {
console.log(
`[workspace/delete] Cancelling draft init for ${input.id}, waiting for completion...`,
);
workspaceInitManager.cancel(input.id);
await workspaceInitManager.waitForInit(input.id, 30000);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After cancelling a draft initialization, verify the initializer has actually stopped before clearing the job and returning success; otherwise a still-running init can re-persist the workspace after deletion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts, line 206:

<comment>After cancelling a draft initialization, verify the initializer has actually stopped before clearing the job and returning success; otherwise a still-running init can re-persist the workspace after deletion.</comment>

<file context>
@@ -173,6 +193,25 @@ export const createDeleteProcedures = () => {
+							`[workspace/delete] Cancelling draft init for ${input.id}, waiting for completion...`,
+						);
+						workspaceInitManager.cancel(input.id);
+						await workspaceInitManager.waitForInit(input.id, 30000);
+					}
+
</file context>
Fix with Cubic

}

workspaceInitManager.clearJob(input.id);
hideProjectIfNoWorkspaces(draftJob.projectId);
track("workspace_deleted", { workspace_id: input.id, draft: true });

return { success: true };
Comment on lines +200 to +213
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't clear the draft job unless the initializer actually stopped.

waitForInit(input.id, 30000) resolves on timeout, so this branch can fall through to clearJob() and return { success: true } while the original init is still running. If that task reaches persistDraftWorkspaceIfNeeded() later, the draft workspace/worktree rows get recreated after the user already deleted them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts` around
lines 200 - 213, The branch clears the draft job and returns success even if
workspaceInitManager.waitForInit(input.id, 30000) timed out and the initializer
is still running, which allows persistDraftWorkspaceIfNeeded() to recreate the
draft; change the logic in the block that calls
workspaceInitManager.cancel(input.id) and await
workspaceInitManager.waitForInit(input.id, 30000) so you check the result (or
re-check workspaceInitManager.isInitializing(input.id)) and only call
workspaceInitManager.clearJob(input.id),
hideProjectIfNoWorkspaces(draftJob.projectId), and track("workspace_deleted",
...) and return { success: true } when the initializer has actually stopped; if
the wait timed out keep the job intact (or surface an error) so the background
initializer cannot later recreate the draft workspace.

}

if (!workspace) {
return { success: false, error: "Workspace not found" };
Expand Down Expand Up @@ -267,43 +306,13 @@ export const createDeleteProcedures = () => {
await workspaceInitManager.acquireProjectLock(project.id);

try {
// Only delete from disk if this worktree was created by Superset
// External worktrees should only have their DB records removed
if (worktree.createdBySuperset) {
// Safety: Double-check it's not actually external (catches race conditions)
const externalWorktrees = await listExternalWorktrees(
project.mainRepoPath,
);
const worktreePathNorm = normalizePath(worktree.path);
const isActuallyExternal = externalWorktrees.some(
(wt) => normalizePath(wt.path) === worktreePathNorm,
);

if (isActuallyExternal) {
console.warn(
`[workspace/delete] Worktree at ${worktree.path} marked as created by Superset but found in external list - preserving as safety measure`,
);
track("worktree_delete_safety_trigger", {
workspace_id: input.id,
worktree_id: worktree.id,
worktree_path: worktree.path,
reason: "external_detection_mismatch",
});
} else {
// Confirmed safe to delete
const removeResult = await removeWorktreeFromDisk({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
if (!removeResult.success) {
clearWorkspaceDeletingStatus(input.id);
return removeResult;
}
}
} else {
console.log(
`[workspace/delete] Skipping disk deletion for external worktree at ${worktree.path}`,
);
const removeResult = await removeWorktreeFromDisk({
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Deletion no longer checks createdBySuperset, so imported external worktrees can now be removed from disk instead of only being unlinked from the DB.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts, line 309:

<comment>Deletion no longer checks `createdBySuperset`, so imported external worktrees can now be removed from disk instead of only being unlinked from the DB.</comment>

<file context>
@@ -267,43 +306,13 @@ export const createDeleteProcedures = () => {
-							console.log(
-								`[workspace/delete] Skipping disk deletion for external worktree at ${worktree.path}`,
-							);
+						const removeResult = await removeWorktreeFromDisk({
+							mainRepoPath: project.mainRepoPath,
+							worktreePath: worktree.path,
</file context>
Fix with Cubic

mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
if (!removeResult.success) {
clearWorkspaceDeletingStatus(input.id);
return removeResult;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
workspaceInitManager.releaseProjectLock(project.id);
Expand Down Expand Up @@ -486,67 +495,40 @@ export const createDeleteProcedures = () => {
worktree.path,
);

// Only delete from disk if this worktree was created by Superset
if (worktree.createdBySuperset) {
// Safety: Double-check it's not actually external (catches race conditions)
const externalWorktrees = await listExternalWorktrees(
project.mainRepoPath,
);
const isActuallyExternal = externalWorktrees.some(
(wt) => wt.path === worktree.path,
);

if (isActuallyExternal) {
console.warn(
`[worktree/delete] Worktree at ${worktree.path} marked as created by Superset but found in external list - preserving as safety measure`,
);
track("worktree_delete_safety_trigger", {
worktree_id: input.worktreeId,
worktree_path: worktree.path,
reason: "external_detection_mismatch",
});
} else {
// Confirmed safe to delete
if (exists) {
const teardownResult = await runTeardown({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
workspaceName: worktree.branch,
projectId: project.id,
});
if (!teardownResult.success) {
if (input.force) {
console.warn(
`[worktree/delete] Teardown failed but force=true, continuing deletion:`,
teardownResult.error,
);
} else {
return {
success: false,
error: `Teardown failed: ${teardownResult.error}`,
output: teardownResult.output,
};
}
}
}

if (exists) {
const removeResult = await removeWorktreeFromDisk({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
if (!removeResult.success) {
return removeResult;
}
} else {
if (exists) {
const teardownResult = await runTeardown({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
workspaceName: worktree.branch,
projectId: project.id,
});
if (!teardownResult.success) {
if (input.force) {
console.warn(
`Worktree ${worktree.path} not found in git, skipping removal`,
`[worktree/delete] Teardown failed but force=true, continuing deletion:`,
teardownResult.error,
);
} else {
return {
success: false,
error: `Teardown failed: ${teardownResult.error}`,
output: teardownResult.output,
};
}
}
}

if (exists) {
const removeResult = await removeWorktreeFromDisk({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
if (!removeResult.success) {
return removeResult;
}
} else {
console.log(
`[worktree/delete] Skipping disk deletion for external worktree at ${worktree.path}`,
console.warn(
`Worktree ${worktree.path} not found in git, skipping removal`,
);
}
} finally {
Expand Down
Loading
Loading