diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 9934a1ab9ae..00ce1bd9f1b 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { projects, workspaces, worktrees } from "@superset/local-db"; +import { projects, workspaces } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; @@ -15,6 +15,10 @@ import { getTerminalHostClient } from "main/lib/terminal-host/client"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + resolveWorktreePathOrThrowWithMetadata, + resolveWorktreePathWithRepair, +} from "../workspaces/utils/repair-worktree-path"; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveTerminalThemeType } from "./theme-type"; @@ -109,9 +113,16 @@ export const createTerminalRouter = () => { .from(workspaces) .where(eq(workspaces.id, workspaceId)) .get(); + const worktreeResolution = + workspace?.type === "worktree" && workspace.worktreeId + ? await resolveWorktreePathOrThrowWithMetadata(workspace.worktreeId) + : null; const workspacePath = workspace - ? (getWorkspacePath(workspace) ?? undefined) + ? workspace.type === "worktree" && workspace.worktreeId + ? (worktreeResolution?.path ?? undefined) + : (getWorkspacePath(workspace) ?? undefined) : undefined; + if (workspace?.type === "worktree") { assertWorkspaceUsable(workspaceId, workspacePath); } @@ -182,6 +193,7 @@ export const createTerminalRouter = () => { return { paneId, isNew: result.isNew, + pathChanged: worktreeResolution?.pathChanged ?? false, scrollback: result.scrollback, wasRecovered: result.wasRecovered, // Cold restore fields (for reboot recovery) @@ -442,7 +454,7 @@ export const createTerminalRouter = () => { getWorkspaceCwd: publicProcedure .input(z.string()) - .query(({ input: workspaceId }) => { + .query(async ({ input: workspaceId }) => { const workspace = localDb .select() .from(workspaces) @@ -456,12 +468,7 @@ export const createTerminalRouter = () => { return null; } - const worktree = localDb - .select() - .from(worktrees) - .where(eq(worktrees.id, workspace.worktreeId)) - .get(); - return worktree?.path ?? null; + return resolveWorktreePathWithRepair(workspace.worktreeId); }), stream: publicProcedure diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index f576ed5dd58..f99136c5a2f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -1,4 +1,9 @@ -import { projects, settings, workspaces, worktrees } from "@superset/local-db"; +import { + projects, + settings, + workspaces, + worktrees, +} from "@superset/local-db/schema"; import { and, eq, isNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; @@ -19,6 +24,10 @@ import { setLastActiveWorkspace, touchWorkspace, } from "../utils/db-helpers"; +import { + listImportableExternalWorktrees, + resolveExternalWorktreeOpenTarget, +} from "../utils/external-worktrees"; import { createWorktreeFromPr, generateBranchName, @@ -28,7 +37,6 @@ import { getPrInfo, getPrLocalBranchName, listBranches, - listExternalWorktrees, type PullRequestInfo, parsePrUrl, safeCheckoutBranch, @@ -36,6 +44,10 @@ import { sanitizeBranchNameWithMaxLength, worktreeExists, } from "../utils/git"; +import { + listProjectWorktreesWithCurrentPaths, + resolveWorktreePathOrThrow, +} from "../utils/repair-worktree-path"; import { resolveWorktreePath } from "../utils/resolve-worktree-path"; import { copySupersetConfigToWorktree, loadSetupConfig } from "../utils/setup"; import { initializeWorkspaceWorktree } from "../utils/workspace-init"; @@ -77,6 +89,12 @@ function getPrWorkspaceName(prInfo: PullRequestInfo): string { return prInfo.title || `PR #${prInfo.number}`; } +async function getTrackedWorktreePath( + worktree: typeof worktrees.$inferSelect, +): Promise { + return (await resolveWorktreePathOrThrow(worktree.id)) ?? worktree.path; +} + interface PrWorkspaceResult { workspace: typeof workspaces.$inferSelect; initialCommands: string[] | null; @@ -95,13 +113,14 @@ interface HandleExistingWorktreeParams { workspaceName: string; } -function handleExistingWorktree({ +async function handleExistingWorktree({ existingWorktree, project, prInfo, localBranchName, workspaceName, -}: HandleExistingWorktreeParams): PrWorkspaceResult { +}: HandleExistingWorktreeParams): Promise { + const worktreePath = await getTrackedWorktreePath(existingWorktree); const existingWorkspace = localDb .select() .from(workspaces) @@ -120,7 +139,7 @@ function handleExistingWorktree({ return { workspace: existingWorkspace, initialCommands: null, - worktreePath: existingWorktree.path, + worktreePath, projectId: project.id, prNumber: prInfo.number, prTitle: prInfo.title, @@ -147,14 +166,14 @@ function handleExistingWorktree({ const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, - worktreePath: existingWorktree.path, + worktreePath, projectId: project.id, }); return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: existingWorktree.path, + worktreePath, projectId: project.id, prNumber: prInfo.number, prTitle: prInfo.title, @@ -388,13 +407,16 @@ export const createCreateProcedures = () => { branch, }); if (existing) { + const worktreePath = await getTrackedWorktreePath( + existing.worktree, + ); touchWorkspace(existing.workspace.id); setLastActiveWorkspace(existing.workspace.id); activateProject(project); return { workspace: existing.workspace, initialCommands: null, - worktreePath: existing.worktree.path, + worktreePath, projectId: project.id, isInitializing: false, wasExisting: true, @@ -406,6 +428,7 @@ export const createCreateProcedures = () => { branch, }); if (orphanedWorktree) { + const worktreePath = await getTrackedWorktreePath(orphanedWorktree); const workspace = createWorkspaceFromWorktree({ projectId: input.projectId, worktreeId: orphanedWorktree.id, @@ -430,13 +453,13 @@ export const createCreateProcedures = () => { activateProject(project); const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, - worktreePath: orphanedWorktree.path, + worktreePath, projectId: project.id, }); return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: orphanedWorktree.path, + worktreePath, projectId: project.id, isInitializing: false, autoRenameWarning, @@ -674,10 +697,8 @@ export const createCreateProcedures = () => { throw new Error(`Project ${worktree.projectId} not found`); } - const exists = await worktreeExists( - project.mainRepoPath, - worktree.path, - ); + const worktreePath = await getTrackedWorktreePath(worktree); + const exists = await worktreeExists(project.mainRepoPath, worktreePath); if (!exists) { throw new Error("Worktree no longer exists on disk"); } @@ -703,7 +724,7 @@ export const createCreateProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, + worktreePath, projectId: project.id, }); @@ -716,7 +737,7 @@ export const createCreateProcedures = () => { return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: worktree.path, + worktreePath, projectId: project.id, }; }), @@ -735,26 +756,21 @@ export const createCreateProcedures = () => { throw new Error(`Project ${input.projectId} not found`); } - const exists = await worktreeExists( - project.mainRepoPath, - input.worktreePath, - ); - if (!exists) { + const worktreeTarget = await resolveExternalWorktreeOpenTarget({ + projectId: input.projectId, + mainRepoPath: project.mainRepoPath, + worktreePath: input.worktreePath, + branch: input.branch, + }); + + if (!worktreeTarget) { throw new Error("Worktree no longer exists on disk"); } - const existingWorktree = localDb - .select() - .from(worktrees) - .where( - and( - eq(worktrees.projectId, input.projectId), - eq(worktrees.path, input.worktreePath), - ), - ) - .get(); - - if (existingWorktree) { + if (worktreeTarget.kind === "tracked") { + const existingWorktree = worktreeTarget.worktree; + const trackedWorktreePath = + await getTrackedWorktreePath(existingWorktree); // Failed init can leave gitStatus null, which shows "Setup incomplete" UI if (!existingWorktree.gitStatus) { localDb @@ -789,7 +805,7 @@ export const createCreateProcedures = () => { return { workspace: existingWorkspace, initialCommands: null, - worktreePath: existingWorktree.path, + worktreePath: trackedWorktreePath, projectId: project.id, wasExisting: true, }; @@ -814,11 +830,11 @@ export const createCreateProcedures = () => { copySupersetConfigToWorktree( project.mainRepoPath, - existingWorktree.path, + trackedWorktreePath, ); const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, - worktreePath: existingWorktree.path, + worktreePath: trackedWorktreePath, projectId: project.id, }); @@ -832,7 +848,7 @@ export const createCreateProcedures = () => { return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: existingWorktree.path, + worktreePath: trackedWorktreePath, projectId: project.id, wasExisting: false, }; @@ -849,11 +865,11 @@ export const createCreateProcedures = () => { .insert(worktrees) .values({ projectId: input.projectId, - path: input.worktreePath, - branch: input.branch, + path: worktreeTarget.worktreePath, + branch: worktreeTarget.branch, baseBranch, gitStatus: { - branch: input.branch, + branch: worktreeTarget.branch, needsRebase: false, ahead: 0, behind: 0, @@ -870,8 +886,8 @@ export const createCreateProcedures = () => { projectId: input.projectId, worktreeId: worktree.id, type: "worktree", - branch: input.branch, - name: input.branch, + branch: worktreeTarget.branch, + name: worktreeTarget.branch, tabOrder: maxTabOrder + 1, }) .returning() @@ -880,24 +896,27 @@ export const createCreateProcedures = () => { setLastActiveWorkspace(workspace.id); activateProject(project); - copySupersetConfigToWorktree(project.mainRepoPath, input.worktreePath); + copySupersetConfigToWorktree( + project.mainRepoPath, + worktreeTarget.worktreePath, + ); const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, - worktreePath: input.worktreePath, + worktreePath: worktreeTarget.worktreePath, projectId: project.id, }); track("workspace_created", { workspace_id: workspace.id, project_id: project.id, - branch: input.branch, + branch: worktreeTarget.branch, base_branch: baseBranch, source: "external_import", }); await setBranchBaseConfig({ repoPath: project.mainRepoPath, - branch: input.branch, + branch: worktreeTarget.branch, baseBranch, isExplicit: false, }); @@ -905,7 +924,7 @@ export const createCreateProcedures = () => { return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: input.worktreePath, + worktreePath: worktreeTarget.worktreePath, projectId: project.id, wasExisting: false, }; @@ -985,13 +1004,12 @@ export const createCreateProcedures = () => { let imported = 0; // 1. Import closed worktrees (tracked in DB but no active workspace) - const projectWorktrees = localDb - .select() - .from(worktrees) - .where(eq(worktrees.projectId, input.projectId)) - .all(); + const projectWorktrees = await listProjectWorktreesWithCurrentPaths( + input.projectId, + ); - for (const wt of projectWorktrees) { + for (const trackedWorktree of projectWorktrees) { + const wt = trackedWorktree.worktree; const existingWorkspace = localDb .select() .from(workspaces) @@ -1005,6 +1023,7 @@ export const createCreateProcedures = () => { if (existingWorkspace) continue; + if (!trackedWorktree.existsOnDisk) continue; const exists = await worktreeExists(project.mainRepoPath, wt.path); if (!exists) continue; @@ -1026,33 +1045,21 @@ export const createCreateProcedures = () => { } // 2. Import external worktrees (on disk, not tracked in DB) - const allExternalWorktrees = await listExternalWorktrees( - project.mainRepoPath, - ); - const trackedPaths = new Set(projectWorktrees.map((wt) => wt.path)); - - const externalWorktrees = allExternalWorktrees.filter((wt) => { - if (wt.path === project.mainRepoPath) return false; - if (wt.isBare) return false; - if (wt.isDetached) return false; - if (!wt.branch) return false; - if (trackedPaths.has(wt.path)) return false; - return true; + const externalWorktrees = await listImportableExternalWorktrees({ + projectId: input.projectId, + mainRepoPath: project.mainRepoPath, }); for (const ext of externalWorktrees) { - // biome-ignore lint/style/noNonNullAssertion: filtered above - const branch = ext.branch!; - const worktree = localDb .insert(worktrees) .values({ projectId: input.projectId, path: ext.path, - branch, + branch: ext.branch, baseBranch, gitStatus: { - branch, + branch: ext.branch, needsRebase: false, ahead: 0, behind: 0, @@ -1069,15 +1076,15 @@ export const createCreateProcedures = () => { projectId: input.projectId, worktreeId: worktree.id, type: "worktree", - branch, - name: branch, + branch: ext.branch, + name: ext.branch, tabOrder: maxTabOrder + 1, }) .run(); await setBranchBaseConfig({ repoPath: project.mainRepoPath, - branch, + branch: ext.branch, baseBranch, isExplicit: false, }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.test.ts new file mode 100644 index 00000000000..eaf67246a59 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.test.ts @@ -0,0 +1,401 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { TRPCError } from "@trpc/server"; + +import { + __testOnlyDeleteProcedureDeps, + createDeleteProcedures, +} from "./delete"; + +type MockWorkspace = NonNullable< + ReturnType +>; +type MockWorktree = NonNullable< + ReturnType +>; +type MockProject = NonNullable< + ReturnType +>; +type HideProjectIfNoWorkspacesFn = + typeof __testOnlyDeleteProcedureDeps.hideProjectIfNoWorkspaces; +type UpdateActiveWorkspaceIfRemovedFn = + typeof __testOnlyDeleteProcedureDeps.updateActiveWorkspaceIfRemoved; +type WorkspaceRuntimeRegistry = ReturnType< + typeof __testOnlyDeleteProcedureDeps.getWorkspaceRuntimeRegistry +>; +type WorkspaceRuntime = ReturnType< + WorkspaceRuntimeRegistry["getForWorkspaceId"] +>; +type TerminalRuntime = WorkspaceRuntime["terminal"]; +type KillByWorkspaceIdFn = TerminalRuntime["killByWorkspaceId"]; +type GetSessionCountByWorkspaceIdFn = + TerminalRuntime["getSessionCountByWorkspaceId"]; +type IsInitializingFn = + typeof __testOnlyDeleteProcedureDeps.workspaceInitManager.isInitializing; +type CancelInitFn = + typeof __testOnlyDeleteProcedureDeps.workspaceInitManager.cancel; +type WaitForInitFn = + typeof __testOnlyDeleteProcedureDeps.workspaceInitManager.waitForInit; +type AcquireProjectLockFn = + typeof __testOnlyDeleteProcedureDeps.workspaceInitManager.acquireProjectLock; +type ReleaseProjectLockFn = + typeof __testOnlyDeleteProcedureDeps.workspaceInitManager.releaseProjectLock; +type ClearJobFn = + typeof __testOnlyDeleteProcedureDeps.workspaceInitManager.clearJob; +type TrackFn = typeof __testOnlyDeleteProcedureDeps.track; +type HasUncommittedChangesFn = + typeof __testOnlyDeleteProcedureDeps.hasUncommittedChanges; +type HasUnpushedCommitsFn = + typeof __testOnlyDeleteProcedureDeps.hasUnpushedCommits; +type WorktreeExistsFn = typeof __testOnlyDeleteProcedureDeps.worktreeExists; +type DeleteLocalBranchFn = + typeof __testOnlyDeleteProcedureDeps.deleteLocalBranch; +type ResolveTrackedWorktreePathFn = + typeof __testOnlyDeleteProcedureDeps.resolveTrackedWorktreePath; +type RunTeardownFn = typeof __testOnlyDeleteProcedureDeps.runTeardown; +type RemoveWorktreeFromDiskFn = + typeof __testOnlyDeleteProcedureDeps.removeWorktreeFromDisk; + +type MockTrackedWorktreePathResult = + | { + status: "resolved"; + path: string; + } + | { + status: "git_repair_required"; + branch: string; + mainRepoPath: string; + registeredPath: string; + storedPath: string; + } + | { + status: "missing"; + }; + +let workspaces: Map; +let worktrees: Map; +let projects: Map; + +const originalDeps = { + ...__testOnlyDeleteProcedureDeps, +}; +const originalWorkspaceInitManagerMethods = { + acquireProjectLock: + __testOnlyDeleteProcedureDeps.workspaceInitManager.acquireProjectLock, + cancel: __testOnlyDeleteProcedureDeps.workspaceInitManager.cancel, + clearJob: __testOnlyDeleteProcedureDeps.workspaceInitManager.clearJob, + isInitializing: + __testOnlyDeleteProcedureDeps.workspaceInitManager.isInitializing, + releaseProjectLock: + __testOnlyDeleteProcedureDeps.workspaceInitManager.releaseProjectLock, + waitForInit: __testOnlyDeleteProcedureDeps.workspaceInitManager.waitForInit, +}; + +const hideProjectIfNoWorkspacesMock = mock( + () => {}, +); +const updateActiveWorkspaceIfRemovedMock = + mock(() => {}); +const killByWorkspaceIdMock = mock(async () => ({ + killed: 0, + failed: 0, +})); +const getSessionCountByWorkspaceIdMock = mock( + async () => 0, +); +const isInitializingMock = mock(() => false); +const cancelInitMock = mock(() => {}); +const waitForInitMock = mock(async () => {}); +const acquireProjectLockMock = mock(async () => {}); +const releaseProjectLockMock = mock(() => {}); +const clearJobMock = mock(() => {}); +const trackMock = mock(() => {}); +const hasUncommittedChangesMock = mock( + async () => false, +); +const hasUnpushedCommitsMock = mock(async () => false); +const worktreeExistsMock = mock(async () => true); +const deleteLocalBranchMock = mock(async () => {}); +const resolveTrackedWorktreePathMock = mock(); +const runTeardownMock = mock(async () => ({ success: true })); +const removeWorktreeFromDiskMock = mock(async () => ({ + success: true as const, +})); + +function createProject(overrides: Partial = {}): MockProject { + return { + id: "proj-1", + mainRepoPath: "/repo/main", + name: "Project 1", + color: "#000000", + tabOrder: 0, + lastOpenedAt: 0, + createdAt: 0, + configToastDismissed: null, + defaultBranch: null, + workspaceBaseBranch: null, + githubOwner: null, + branchPrefixMode: null, + branchPrefixCustom: null, + worktreeBaseDir: null, + hideImage: null, + iconUrl: null, + neonProjectId: null, + defaultApp: null, + ...overrides, + }; +} + +function createWorktree(overrides: Partial = {}): MockWorktree { + return { + id: "wt-1", + projectId: "proj-1", + path: "/repo/wt-old", + branch: "feat-move", + baseBranch: null, + createdAt: 0, + gitStatus: null, + githubStatus: null, + ...overrides, + }; +} + +function createWorkspace( + overrides: Partial = {}, +): MockWorkspace { + return { + id: "ws-1", + projectId: "proj-1", + worktreeId: "wt-1", + type: "worktree", + name: "feat-move", + branch: "feat-move", + tabOrder: 0, + createdAt: 0, + updatedAt: 0, + lastOpenedAt: 0, + isUnread: false, + isUnnamed: false, + deletingAt: null, + portBase: null, + sectionId: null, + ...overrides, + }; +} + +function createCaller() { + return createDeleteProcedures().createCaller({}); +} + +function seedTrackedWorkspace() { + projects.set("proj-1", createProject()); + worktrees.set("wt-1", createWorktree()); + workspaces.set("ws-1", createWorkspace()); +} + +function buildRepairRequiredResult(): MockTrackedWorktreePathResult { + return { + status: "git_repair_required", + branch: "feat-move", + mainRepoPath: "/repo/main", + registeredPath: "/elsewhere/wt-new", + storedPath: "/repo/wt-old", + }; +} + +describe("delete procedures", () => { + beforeEach(() => { + workspaces = new Map(); + worktrees = new Map(); + projects = new Map(); + + hideProjectIfNoWorkspacesMock.mockClear(); + updateActiveWorkspaceIfRemovedMock.mockClear(); + killByWorkspaceIdMock.mockClear(); + getSessionCountByWorkspaceIdMock.mockClear(); + isInitializingMock.mockClear(); + cancelInitMock.mockClear(); + waitForInitMock.mockClear(); + acquireProjectLockMock.mockClear(); + releaseProjectLockMock.mockClear(); + clearJobMock.mockClear(); + trackMock.mockClear(); + hasUncommittedChangesMock.mockClear(); + hasUnpushedCommitsMock.mockClear(); + worktreeExistsMock.mockClear(); + deleteLocalBranchMock.mockClear(); + resolveTrackedWorktreePathMock.mockClear(); + runTeardownMock.mockClear(); + removeWorktreeFromDiskMock.mockClear(); + + isInitializingMock.mockReturnValue(false); + killByWorkspaceIdMock.mockResolvedValue({ killed: 0, failed: 0 }); + getSessionCountByWorkspaceIdMock.mockResolvedValue(0); + waitForInitMock.mockResolvedValue(undefined); + acquireProjectLockMock.mockResolvedValue(undefined); + releaseProjectLockMock.mockReturnValue(undefined); + clearJobMock.mockReturnValue(undefined); + hasUncommittedChangesMock.mockResolvedValue(false); + hasUnpushedCommitsMock.mockResolvedValue(false); + worktreeExistsMock.mockResolvedValue(true); + deleteLocalBranchMock.mockResolvedValue(undefined); + resolveTrackedWorktreePathMock.mockResolvedValue({ + status: "resolved", + path: "/repo/wt-old", + }); + runTeardownMock.mockResolvedValue({ success: true }); + removeWorktreeFromDiskMock.mockResolvedValue({ success: true }); + + __testOnlyDeleteProcedureDeps.clearWorkspaceDeletingStatus = ( + workspaceId: string, + ) => { + const workspace = workspaces.get(workspaceId); + if (workspace) { + workspace.deletingAt = null; + } + }; + __testOnlyDeleteProcedureDeps.deleteLocalBranch = (...args) => + deleteLocalBranchMock(...args); + __testOnlyDeleteProcedureDeps.deleteWorkspace = (workspaceId: string) => { + workspaces.delete(workspaceId); + }; + __testOnlyDeleteProcedureDeps.deleteWorktreeRecord = ( + worktreeId: string, + ) => { + worktrees.delete(worktreeId); + }; + __testOnlyDeleteProcedureDeps.getProject = (projectId: string) => + projects.get(projectId); + __testOnlyDeleteProcedureDeps.getWorkspace = (workspaceId: string) => + workspaces.get(workspaceId); + const terminalRuntime = { + killByWorkspaceId: (...args: Parameters) => + killByWorkspaceIdMock(...args), + getSessionCountByWorkspaceId: ( + ...args: Parameters + ) => getSessionCountByWorkspaceIdMock(...args), + } as unknown as TerminalRuntime; + const workspaceRuntime = { + terminal: terminalRuntime, + } as unknown as WorkspaceRuntime; + __testOnlyDeleteProcedureDeps.getWorkspaceRuntimeRegistry = () => + ({ + getForWorkspaceId: () => workspaceRuntime, + getDefault: () => workspaceRuntime, + }) as unknown as WorkspaceRuntimeRegistry; + __testOnlyDeleteProcedureDeps.getWorktree = (worktreeId: string) => + worktrees.get(worktreeId); + __testOnlyDeleteProcedureDeps.hasUncommittedChanges = (...args) => + hasUncommittedChangesMock(...args); + __testOnlyDeleteProcedureDeps.hasUnpushedCommits = (...args) => + hasUnpushedCommitsMock(...args); + __testOnlyDeleteProcedureDeps.hideProjectIfNoWorkspaces = (...args) => + hideProjectIfNoWorkspacesMock(...args); + __testOnlyDeleteProcedureDeps.markWorkspaceAsDeleting = ( + workspaceId: string, + ) => { + const workspace = workspaces.get(workspaceId); + if (workspace) { + workspace.deletingAt = Date.now(); + } + }; + __testOnlyDeleteProcedureDeps.removeWorktreeFromDisk = (...args) => + removeWorktreeFromDiskMock(...args); + __testOnlyDeleteProcedureDeps.resolveTrackedWorktreePath = (...args) => + resolveTrackedWorktreePathMock(...args); + __testOnlyDeleteProcedureDeps.runTeardown = (...args) => + runTeardownMock(...args); + __testOnlyDeleteProcedureDeps.track = (...args) => trackMock(...args); + __testOnlyDeleteProcedureDeps.updateActiveWorkspaceIfRemoved = (...args) => + updateActiveWorkspaceIfRemovedMock(...args); + __testOnlyDeleteProcedureDeps.worktreeExists = (...args) => + worktreeExistsMock(...args); + __testOnlyDeleteProcedureDeps.workspaceInitManager.acquireProjectLock = ( + ...args + ) => acquireProjectLockMock(...args); + __testOnlyDeleteProcedureDeps.workspaceInitManager.cancel = (...args) => + cancelInitMock(...args); + __testOnlyDeleteProcedureDeps.workspaceInitManager.clearJob = (...args) => + clearJobMock(...args); + __testOnlyDeleteProcedureDeps.workspaceInitManager.isInitializing = ( + ...args + ) => isInitializingMock(...args); + __testOnlyDeleteProcedureDeps.workspaceInitManager.releaseProjectLock = ( + ...args + ) => releaseProjectLockMock(...args); + __testOnlyDeleteProcedureDeps.workspaceInitManager.waitForInit = ( + ...args + ) => waitForInitMock(...args); + }); + + afterAll(() => { + Object.assign(__testOnlyDeleteProcedureDeps, originalDeps); + Object.assign( + __testOnlyDeleteProcedureDeps.workspaceInitManager, + originalWorkspaceInitManagerMethods, + ); + }); + + test("delete clears deletingAt when tracked worktree resolution throws", async () => { + seedTrackedWorkspace(); + resolveTrackedWorktreePathMock.mockImplementationOnce(async () => { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Run git worktree repair", + }); + }); + + await expect(createCaller().delete({ id: "ws-1" })).rejects.toThrow( + "Run git worktree repair", + ); + expect(workspaces.get("ws-1")?.deletingAt).toBeNull(); + }); + + test("canDelete keeps moved worktree workspaces deletable when repair is required", async () => { + seedTrackedWorkspace(); + resolveTrackedWorktreePathMock.mockResolvedValue( + buildRepairRequiredResult(), + ); + + const result = await createCaller().canDelete({ id: "ws-1" }); + + expect(result.canDelete).toBe(true); + expect(result.warning).toContain( + "Delete will fall back to the stored path", + ); + expect(worktreeExistsMock).not.toHaveBeenCalled(); + }); + + test("canDeleteWorktree keeps moved tracked worktrees deletable when repair is required", async () => { + seedTrackedWorkspace(); + resolveTrackedWorktreePathMock.mockResolvedValue( + buildRepairRequiredResult(), + ); + + const result = await createCaller().canDeleteWorktree({ + worktreeId: "wt-1", + }); + + expect(result.canDelete).toBe(true); + expect(result.warning).toContain( + "Delete will fall back to the stored path", + ); + expect(worktreeExistsMock).not.toHaveBeenCalled(); + }); + + test("deleteWorktree falls back to the stored path when repair is required", async () => { + seedTrackedWorkspace(); + resolveTrackedWorktreePathMock.mockResolvedValue( + buildRepairRequiredResult(), + ); + + const result = await createCaller().deleteWorktree({ worktreeId: "wt-1" }); + + expect(result).toEqual({ success: true }); + expect(runTeardownMock).not.toHaveBeenCalled(); + expect(removeWorktreeFromDiskMock).toHaveBeenCalledWith({ + mainRepoPath: "/repo/main", + worktreePath: "/repo/wt-old", + }); + expect(worktrees.has("wt-1")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 21cf02c02b1..5f18c1e0ae6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -22,8 +22,99 @@ import { hasUnpushedCommits, worktreeExists, } from "../utils/git"; +import { + getTrackedWorktreeRepairMessage, + type ResolveTrackedWorktreePathResult, + resolveTrackedWorktreePath, +} from "../utils/repair-worktree-path"; import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown"; +interface CleanupTrackedWorktreePath { + path: string; + usesFallbackPath: boolean; + warning: string | null; +} + +interface DeleteProcedureDeps { + clearWorkspaceDeletingStatus: typeof clearWorkspaceDeletingStatus; + deleteLocalBranch: typeof deleteLocalBranch; + deleteWorkspace: typeof deleteWorkspace; + deleteWorktreeRecord: typeof deleteWorktreeRecord; + getProject: typeof getProject; + getWorkspace: typeof getWorkspace; + getWorkspaceRuntimeRegistry: typeof getWorkspaceRuntimeRegistry; + getWorktree: typeof getWorktree; + hasUncommittedChanges: typeof hasUncommittedChanges; + hasUnpushedCommits: typeof hasUnpushedCommits; + hideProjectIfNoWorkspaces: typeof hideProjectIfNoWorkspaces; + markWorkspaceAsDeleting: typeof markWorkspaceAsDeleting; + removeWorktreeFromDisk: typeof removeWorktreeFromDisk; + resolveTrackedWorktreePath: typeof resolveTrackedWorktreePath; + runTeardown: typeof runTeardown; + track: typeof track; + updateActiveWorkspaceIfRemoved: typeof updateActiveWorkspaceIfRemoved; + workspaceInitManager: typeof workspaceInitManager; + worktreeExists: typeof worktreeExists; +} + +export const __testOnlyDeleteProcedureDeps: DeleteProcedureDeps = { + clearWorkspaceDeletingStatus, + deleteLocalBranch, + deleteWorkspace, + deleteWorktreeRecord, + getProject, + getWorkspace, + getWorkspaceRuntimeRegistry, + getWorktree, + hasUncommittedChanges, + hasUnpushedCommits, + hideProjectIfNoWorkspaces, + markWorkspaceAsDeleting, + removeWorktreeFromDisk, + resolveTrackedWorktreePath, + runTeardown, + track, + updateActiveWorkspaceIfRemoved, + workspaceInitManager, + worktreeExists, +}; + +function getCleanupFallbackWarning( + resolution: Exclude, +): string { + if (resolution.status === "git_repair_required") { + return `Worktree was moved and could not be auto-repaired. Delete will fall back to the stored path. ${getTrackedWorktreeRepairMessage( + { + branch: resolution.branch, + mainRepoPath: resolution.mainRepoPath, + }, + )}`; + } + + return "Tracked worktree path no longer exists on disk. Delete will remove the Superset record and skip any on-disk teardown."; +} + +async function resolveTrackedWorktreePathForCleanup( + worktree: SelectWorktree, +): Promise { + const resolution = + await __testOnlyDeleteProcedureDeps.resolveTrackedWorktreePath(worktree.id); + + if (resolution.status === "resolved") { + return { + path: resolution.path, + usesFallbackPath: false, + warning: null, + }; + } + + return { + path: worktree.path, + usesFallbackPath: true, + warning: getCleanupFallbackWarning(resolution), + }; +} + export const createDeleteProcedures = () => { return router({ canDelete: publicProcedure @@ -34,7 +125,7 @@ export const createDeleteProcedures = () => { }), ) .query(async ({ input }) => { - const workspace = getWorkspace(input.id); + const workspace = __testOnlyDeleteProcedureDeps.getWorkspace(input.id); if (!workspace) { return { @@ -58,7 +149,8 @@ export const createDeleteProcedures = () => { }; } - const activeTerminalCount = await getWorkspaceRuntimeRegistry() + const activeTerminalCount = await __testOnlyDeleteProcedureDeps + .getWorkspaceRuntimeRegistry() .getForWorkspaceId(input.id) .terminal.getSessionCountByWorkspaceId(input.id); @@ -87,15 +179,32 @@ export const createDeleteProcedures = () => { } const worktree = workspace.worktreeId - ? getWorktree(workspace.worktreeId) + ? __testOnlyDeleteProcedureDeps.getWorktree(workspace.worktreeId) : null; - const project = getProject(workspace.projectId); + const project = __testOnlyDeleteProcedureDeps.getProject( + workspace.projectId, + ); if (worktree && project) { try { - const exists = await worktreeExists( + const pathResolution = + await resolveTrackedWorktreePathForCleanup(worktree); + if (pathResolution.usesFallbackPath) { + return { + canDelete: true, + reason: null, + workspace, + warning: pathResolution.warning, + activeTerminalCount, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + + const worktreePath = pathResolution.path; + const exists = await __testOnlyDeleteProcedureDeps.worktreeExists( project.mainRepoPath, - worktree.path, + worktreePath, ); if (!exists) { @@ -112,8 +221,8 @@ export const createDeleteProcedures = () => { } const [hasChanges, unpushedCommits] = await Promise.all([ - hasUncommittedChanges(worktree.path), - hasUnpushedCommits(worktree.path), + __testOnlyDeleteProcedureDeps.hasUncommittedChanges(worktreePath), + __testOnlyDeleteProcedureDeps.hasUnpushedCommits(worktreePath), ]); return { @@ -157,7 +266,7 @@ export const createDeleteProcedures = () => { }), ) .mutation(async ({ input }) => { - const workspace = getWorkspace(input.id); + const workspace = __testOnlyDeleteProcedureDeps.getWorkspace(input.id); if (!workspace) { return { success: false, error: "Workspace not found" }; @@ -167,163 +276,211 @@ export const createDeleteProcedures = () => { `[workspace/delete] Starting deletion of "${workspace.name}" (${input.id})`, ); - markWorkspaceAsDeleting(input.id); - updateActiveWorkspaceIfRemoved(input.id); - - if (workspaceInitManager.isInitializing(input.id)) { - console.log( - `[workspace/delete] Cancelling init for ${input.id}, waiting for completion...`, + __testOnlyDeleteProcedureDeps.markWorkspaceAsDeleting(input.id); + try { + __testOnlyDeleteProcedureDeps.updateActiveWorkspaceIfRemoved( + input.id, ); - workspaceInitManager.cancel(input.id); - try { - await workspaceInitManager.waitForInit(input.id, 30000); - } catch (error) { - console.error( - `[workspace/delete] Failed to wait for init cancellation:`, - error, + + if ( + __testOnlyDeleteProcedureDeps.workspaceInitManager.isInitializing( + input.id, + ) + ) { + console.log( + `[workspace/delete] Cancelling init for ${input.id}, waiting for completion...`, ); - clearWorkspaceDeletingStatus(input.id); - return { - success: false, - error: - "Failed to cancel workspace initialization. Please try again.", - }; + __testOnlyDeleteProcedureDeps.workspaceInitManager.cancel(input.id); + try { + await __testOnlyDeleteProcedureDeps.workspaceInitManager.waitForInit( + input.id, + 30000, + ); + } catch (error) { + console.error( + `[workspace/delete] Failed to wait for init cancellation:`, + error, + ); + __testOnlyDeleteProcedureDeps.clearWorkspaceDeletingStatus( + input.id, + ); + return { + success: false, + error: + "Failed to cancel workspace initialization. Please try again.", + }; + } } - } - - const project = getProject(workspace.projectId); - let worktree: SelectWorktree | undefined; + const project = __testOnlyDeleteProcedureDeps.getProject( + workspace.projectId, + ); - const terminalPromise = getWorkspaceRuntimeRegistry() - .getForWorkspaceId(input.id) - .terminal.killByWorkspaceId(input.id); + let worktree: SelectWorktree | undefined; + let worktreePath: string | undefined; - let teardownPromise: - | Promise<{ success: boolean; error?: string; output?: string }> - | undefined; - if (workspace.type === "worktree" && workspace.worktreeId) { - worktree = getWorktree(workspace.worktreeId); + const terminalPromise = __testOnlyDeleteProcedureDeps + .getWorkspaceRuntimeRegistry() + .getForWorkspaceId(input.id) + .terminal.killByWorkspaceId(input.id); - if (worktree && project && existsSync(worktree.path)) { - teardownPromise = runTeardown({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - workspaceName: workspace.name, - projectId: project.id, - }); - } else { - console.warn( - `[workspace/delete] Skipping teardown: worktree=${!!worktree}, project=${!!project}, pathExists=${worktree ? existsSync(worktree.path) : "N/A"}`, + let teardownPromise: + | Promise<{ success: boolean; error?: string; output?: string }> + | undefined; + if (workspace.type === "worktree" && workspace.worktreeId) { + worktree = __testOnlyDeleteProcedureDeps.getWorktree( + workspace.worktreeId, ); - } - } else { - console.log( - `[workspace/delete] No teardown needed: type=${workspace.type}, worktreeId=${workspace.worktreeId ?? "null"}`, - ); - } + const pathResolution = worktree + ? await resolveTrackedWorktreePathForCleanup(worktree) + : null; + worktreePath = pathResolution?.path; - const [terminalResult, teardownResult] = await Promise.all([ - terminalPromise, - teardownPromise ?? Promise.resolve({ success: true as const }), - ]); + if (pathResolution?.warning) { + console.warn(`[workspace/delete] ${pathResolution.warning}`); + } - if (teardownResult && !teardownResult.success) { - if (input.force) { - console.warn( - `[workspace/delete] Teardown failed but force=true, continuing deletion:`, - teardownResult.error, - ); + if (worktreePath && project && existsSync(worktreePath)) { + teardownPromise = __testOnlyDeleteProcedureDeps.runTeardown({ + mainRepoPath: project.mainRepoPath, + worktreePath, + workspaceName: workspace.name, + projectId: project.id, + }); + } else { + console.warn( + `[workspace/delete] Skipping teardown: worktree=${!!worktree}, project=${!!project}, pathExists=${worktreePath ? existsSync(worktreePath) : "N/A"}`, + ); + } } else { - console.error( - `[workspace/delete] Teardown failed:`, - teardownResult.error, + console.log( + `[workspace/delete] No teardown needed: type=${workspace.type}, worktreeId=${workspace.worktreeId ?? "null"}`, ); - clearWorkspaceDeletingStatus(input.id); - return { - success: false, - error: `Teardown failed: ${teardownResult.error}`, - output: teardownResult.output, - }; } - } - if (worktree && project) { - await workspaceInitManager.acquireProjectLock(project.id); + const [terminalResult, teardownResult] = await Promise.all([ + terminalPromise, + teardownPromise ?? Promise.resolve({ success: true as const }), + ]); - try { - const removeResult = await removeWorktreeFromDisk({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - }); - if (!removeResult.success) { - clearWorkspaceDeletingStatus(input.id); - return removeResult; + if (teardownResult && !teardownResult.success) { + if (input.force) { + console.warn( + `[workspace/delete] Teardown failed but force=true, continuing deletion:`, + teardownResult.error, + ); + } else { + console.error( + `[workspace/delete] Teardown failed:`, + teardownResult.error, + ); + __testOnlyDeleteProcedureDeps.clearWorkspaceDeletingStatus( + input.id, + ); + return { + success: false, + error: `Teardown failed: ${teardownResult.error}`, + output: teardownResult.output, + }; } - } finally { - workspaceInitManager.releaseProjectLock(project.id); } - if (input.deleteLocalBranch && workspace.branch) { + if (worktree && project) { + await __testOnlyDeleteProcedureDeps.workspaceInitManager.acquireProjectLock( + project.id, + ); + try { - await deleteLocalBranch({ - mainRepoPath: project.mainRepoPath, - branch: workspace.branch, - }); - } catch (error) { - console.error( - `[workspace/delete] Branch cleanup failed (non-blocking):`, - error instanceof Error ? error.message : String(error), + const removeResult = + await __testOnlyDeleteProcedureDeps.removeWorktreeFromDisk({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktreePath ?? worktree.path, + }); + if (!removeResult.success) { + __testOnlyDeleteProcedureDeps.clearWorkspaceDeletingStatus( + input.id, + ); + return removeResult; + } + } finally { + __testOnlyDeleteProcedureDeps.workspaceInitManager.releaseProjectLock( + project.id, ); } + + if (input.deleteLocalBranch && workspace.branch) { + try { + await __testOnlyDeleteProcedureDeps.deleteLocalBranch({ + mainRepoPath: project.mainRepoPath, + branch: workspace.branch, + }); + } catch (error) { + console.error( + `[workspace/delete] Branch cleanup failed (non-blocking):`, + error instanceof Error ? error.message : String(error), + ); + } + } } - } - deleteWorkspace(input.id); + __testOnlyDeleteProcedureDeps.deleteWorkspace(input.id); - if (worktree) { - deleteWorktreeRecord(worktree.id); - } + if (worktree) { + __testOnlyDeleteProcedureDeps.deleteWorktreeRecord(worktree.id); + } - if (project) { - hideProjectIfNoWorkspaces(workspace.projectId); - } + if (project) { + __testOnlyDeleteProcedureDeps.hideProjectIfNoWorkspaces( + workspace.projectId, + ); + } - const terminalWarning = - terminalResult.failed > 0 - ? `${terminalResult.failed} terminal process(es) may still be running` - : undefined; + const terminalWarning = + terminalResult.failed > 0 + ? `${terminalResult.failed} terminal process(es) may still be running` + : undefined; - track("workspace_deleted", { workspace_id: input.id }); + __testOnlyDeleteProcedureDeps.track("workspace_deleted", { + workspace_id: input.id, + }); - workspaceInitManager.clearJob(input.id); + __testOnlyDeleteProcedureDeps.workspaceInitManager.clearJob(input.id); - return { success: true, terminalWarning }; + return { success: true, terminalWarning }; + } catch (error) { + __testOnlyDeleteProcedureDeps.clearWorkspaceDeletingStatus(input.id); + throw error; + } }), close: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { - const workspace = getWorkspace(input.id); + const workspace = __testOnlyDeleteProcedureDeps.getWorkspace(input.id); if (!workspace) { throw new Error("Workspace not found"); } - const terminalResult = await getWorkspaceRuntimeRegistry() + const terminalResult = await __testOnlyDeleteProcedureDeps + .getWorkspaceRuntimeRegistry() .getForWorkspaceId(input.id) .terminal.killByWorkspaceId(input.id); - deleteWorkspace(input.id); - hideProjectIfNoWorkspaces(workspace.projectId); - updateActiveWorkspaceIfRemoved(input.id); + __testOnlyDeleteProcedureDeps.deleteWorkspace(input.id); + __testOnlyDeleteProcedureDeps.hideProjectIfNoWorkspaces( + workspace.projectId, + ); + __testOnlyDeleteProcedureDeps.updateActiveWorkspaceIfRemoved(input.id); const terminalWarning = terminalResult.failed > 0 ? `${terminalResult.failed} terminal process(es) may still be running` : undefined; - track("workspace_closed", { workspace_id: input.id }); + __testOnlyDeleteProcedureDeps.track("workspace_closed", { + workspace_id: input.id, + }); return { success: true, terminalWarning }; }), @@ -336,7 +493,9 @@ export const createDeleteProcedures = () => { }), ) .query(async ({ input }) => { - const worktree = getWorktree(input.worktreeId); + const worktree = __testOnlyDeleteProcedureDeps.getWorktree( + input.worktreeId, + ); if (!worktree) { return { @@ -348,7 +507,9 @@ export const createDeleteProcedures = () => { }; } - const project = getProject(worktree.projectId); + const project = __testOnlyDeleteProcedureDeps.getProject( + worktree.projectId, + ); if (!project) { return { @@ -372,9 +533,23 @@ export const createDeleteProcedures = () => { } try { - const exists = await worktreeExists( + const pathResolution = + await resolveTrackedWorktreePathForCleanup(worktree); + if (pathResolution.usesFallbackPath) { + return { + canDelete: true, + reason: null, + worktree, + warning: pathResolution.warning, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + + const worktreePath = pathResolution.path; + const exists = await __testOnlyDeleteProcedureDeps.worktreeExists( project.mainRepoPath, - worktree.path, + worktreePath, ); if (!exists) { @@ -390,8 +565,8 @@ export const createDeleteProcedures = () => { } const [hasChanges, unpushedCommits] = await Promise.all([ - hasUncommittedChanges(worktree.path), - hasUnpushedCommits(worktree.path), + __testOnlyDeleteProcedureDeps.hasUncommittedChanges(worktreePath), + __testOnlyDeleteProcedureDeps.hasUnpushedCommits(worktreePath), ]); return { @@ -421,33 +596,43 @@ export const createDeleteProcedures = () => { }), ) .mutation(async ({ input }) => { - const worktree = getWorktree(input.worktreeId); + const worktree = __testOnlyDeleteProcedureDeps.getWorktree( + input.worktreeId, + ); if (!worktree) { return { success: false, error: "Worktree not found" }; } - const project = getProject(worktree.projectId); + const project = __testOnlyDeleteProcedureDeps.getProject( + worktree.projectId, + ); if (!project) { return { success: false, error: "Project not found" }; } - await workspaceInitManager.acquireProjectLock(project.id); + await __testOnlyDeleteProcedureDeps.workspaceInitManager.acquireProjectLock( + project.id, + ); try { - const exists = await worktreeExists( - project.mainRepoPath, - worktree.path, - ); + const pathResolution = + await resolveTrackedWorktreePathForCleanup(worktree); + const worktreePath = pathResolution.path; - if (exists) { - const teardownResult = await runTeardown({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - workspaceName: worktree.branch, - projectId: project.id, - }); + if (pathResolution.warning) { + console.warn(`[worktree/delete] ${pathResolution.warning}`); + } + + if (existsSync(worktreePath)) { + const teardownResult = + await __testOnlyDeleteProcedureDeps.runTeardown({ + mainRepoPath: project.mainRepoPath, + worktreePath, + workspaceName: worktree.branch, + projectId: project.id, + }); if (!teardownResult.success) { if (input.force) { console.warn( @@ -464,27 +649,28 @@ export const createDeleteProcedures = () => { } } - if (exists) { - const removeResult = await removeWorktreeFromDisk({ + const removeResult = + await __testOnlyDeleteProcedureDeps.removeWorktreeFromDisk({ mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, + worktreePath, }); - if (!removeResult.success) { - return removeResult; - } - } else { - console.warn( - `Worktree ${worktree.path} not found in git, skipping removal`, - ); + if (!removeResult.success) { + return removeResult; } } finally { - workspaceInitManager.releaseProjectLock(project.id); + __testOnlyDeleteProcedureDeps.workspaceInitManager.releaseProjectLock( + project.id, + ); } - deleteWorktreeRecord(input.worktreeId); - hideProjectIfNoWorkspaces(worktree.projectId); + __testOnlyDeleteProcedureDeps.deleteWorktreeRecord(input.worktreeId); + __testOnlyDeleteProcedureDeps.hideProjectIfNoWorkspaces( + worktree.projectId, + ); - track("worktree_deleted", { worktree_id: input.worktreeId }); + __testOnlyDeleteProcedureDeps.track("worktree_deleted", { + worktree_id: input.worktreeId, + }); return { success: true }; }), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index c07ff59097c..33a93af802f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; -import { workspaces, worktrees } from "@superset/local-db"; +import { workspaces, worktrees } from "@superset/local-db/schema"; +import { TRPCError } from "@trpc/server"; import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; @@ -10,14 +11,19 @@ import { getWorktree, updateProjectDefaultBranch, } from "../utils/db-helpers"; +import { listImportableExternalWorktrees } from "../utils/external-worktrees"; import { fetchDefaultBranch, getAheadBehindCount, getDefaultBranch, - listExternalWorktrees, refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; +import { + listProjectWorktreesWithCurrentPaths, + resolveWorktreePathOrThrow, + resolveWorktreePathWithRepair, +} from "../utils/repair-worktree-path"; export const createGitStatusProcedures = () => { return router({ @@ -61,8 +67,20 @@ export const createGitStatusProcedures = () => { await fetchDefaultBranch(project.mainRepoPath, defaultBranch); + // Repair stale worktree path if directory was moved/unnested + const worktreePath = + (await resolveWorktreePathOrThrow(worktree.id)) ?? worktree.path; + + if (!existsSync(worktreePath)) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Worktree path does not exist on disk", + cause: { reason: "path_missing", path: worktreePath }, + }); + } + const { ahead, behind } = await getAheadBehindCount({ - repoPath: worktree.path, + repoPath: worktreePath, defaultBranch, }); @@ -117,7 +135,12 @@ export const createGitStatusProcedures = () => { return null; } - const freshStatus = await fetchGitHubPRStatus(worktree.path); + const worktreePath = await resolveWorktreePathWithRepair(worktree.id); + if (!worktreePath) { + return null; + } + + const freshStatus = await fetchGitHubPRStatus(worktreePath); if (freshStatus) { localDb @@ -132,7 +155,7 @@ export const createGitStatusProcedures = () => { getWorktreeInfo: publicProcedure .input(z.object({ workspaceId: z.string() })) - .query(({ input }) => { + .query(async ({ input }) => { const workspace = getWorkspace(input.workspaceId); if (!workspace) { return null; @@ -145,7 +168,9 @@ export const createGitStatusProcedures = () => { return null; } - const worktreeName = worktree.path.split("/").pop() ?? worktree.branch; + const worktreePath = + (await resolveWorktreePathWithRepair(worktree.id)) ?? worktree.path; + const worktreeName = worktreePath.split("/").pop() ?? worktree.branch; const branchName = worktree.branch; return { @@ -159,28 +184,26 @@ export const createGitStatusProcedures = () => { getWorktreesByProject: publicProcedure .input(z.object({ projectId: z.string() })) - .query(({ input }) => { - const projectWorktrees = localDb - .select() - .from(worktrees) - .where(eq(worktrees.projectId, input.projectId)) - .all(); - - return projectWorktrees.map((wt) => { + .query(async ({ input }) => { + const projectWorktrees = await listProjectWorktreesWithCurrentPaths( + input.projectId, + ); + + return projectWorktrees.map(({ worktree, existsOnDisk }) => { const workspace = localDb .select() .from(workspaces) .where( and( - eq(workspaces.worktreeId, wt.id), + eq(workspaces.worktreeId, worktree.id), isNull(workspaces.deletingAt), ), ) .get(); return { - ...wt, + ...worktree, hasActiveWorkspace: workspace !== undefined, - existsOnDisk: existsSync(wt.path), + existsOnDisk, workspace: workspace ?? null, }; }); @@ -194,29 +217,10 @@ export const createGitStatusProcedures = () => { return []; } - const allWorktrees = await listExternalWorktrees(project.mainRepoPath); - - const trackedWorktrees = localDb - .select({ path: worktrees.path }) - .from(worktrees) - .where(eq(worktrees.projectId, input.projectId)) - .all(); - const trackedPaths = new Set(trackedWorktrees.map((wt) => wt.path)); - - return allWorktrees - .filter((wt) => { - if (wt.path === project.mainRepoPath) return false; - if (wt.isBare) return false; - if (wt.isDetached) return false; - if (!wt.branch) return false; - if (trackedPaths.has(wt.path)) return false; - return true; - }) - .map((wt) => ({ - path: wt.path, - // biome-ignore lint/style/noNonNullAssertion: filtered above - branch: wt.branch!, - })); + return listImportableExternalWorktrees({ + projectId: input.projectId, + mainRepoPath: project.mainRepoPath, + }); }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index fe676f1018b..2269563be00 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,5 +1,6 @@ import { projects, + type SelectWorkspace, workspaceSections, workspaces, worktrees, @@ -11,10 +12,74 @@ import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getWorkspace } from "../utils/db-helpers"; import { getProjectChildItems } from "../utils/project-children-order"; +import { + getTrackedWorktreeDisplayStateFromTrackedWorktree, + resolveWorktreePathWithRepairMetadata, + type TrackedWorktreeRepairState, +} from "../utils/repair-worktree-path"; import { computeVisualOrder } from "../utils/visual-order"; import { getWorkspacePath } from "../utils/worktree"; -type WorktreePathMap = Map; +interface WorkspacePathQueryState { + existsOnDisk: boolean; + repairCommand: string | null; + repairMessage: string | null; + repairState: TrackedWorktreeRepairState; + worktreePath: string; +} + +async function getWorkspacePathForQuery(input: { + mode: "display" | "repair"; + projectMainRepoPath: string | null; + workspace: SelectWorkspace; + worktree: typeof worktrees.$inferSelect | null; +}): Promise { + const { workspace, worktree, projectMainRepoPath, mode } = input; + + if (workspace.type === "branch") { + return { + existsOnDisk: true, + repairCommand: null, + repairMessage: null, + repairState: "ok", + worktreePath: getWorkspacePath(workspace) ?? "", + }; + } + + if (!workspace.worktreeId || !worktree || !projectMainRepoPath) { + return { + existsOnDisk: false, + repairCommand: null, + repairMessage: "Tracked worktree could not be found.", + repairState: "missing", + worktreePath: "", + }; + } + + if (mode === "repair") { + const resolution = await resolveWorktreePathWithRepairMetadata(worktree.id); + return { + existsOnDisk: resolution.path !== null, + repairCommand: resolution.repairCommand, + repairMessage: resolution.repairMessage, + repairState: resolution.repairState, + worktreePath: resolution.path ?? "", + }; + } + + const displayState = getTrackedWorktreeDisplayStateFromTrackedWorktree({ + mainRepoPath: projectMainRepoPath, + worktree, + }); + + return { + existsOnDisk: displayState.existsOnDisk, + repairCommand: displayState.repairCommand, + repairMessage: displayState.repairMessage, + repairState: displayState.repairState, + worktreePath: displayState.worktreePath ?? "", + }; +} /** Returns workspace IDs in sidebar visual order (by project.tabOrder, then ungrouped workspaces, then sections by tabOrder). */ function getWorkspacesInVisualOrder(): string[] { @@ -54,17 +119,27 @@ export const createQueryProcedures = () => { .where(eq(projects.id, workspace.projectId)) .get(); const worktree = workspace.worktreeId - ? localDb + ? (localDb .select() .from(worktrees) .where(eq(worktrees.id, workspace.worktreeId)) - .get() + .get() ?? null) : null; + const queryState = await getWorkspacePathForQuery({ + mode: "repair", + projectMainRepoPath: project?.mainRepoPath ?? null, + workspace, + worktree, + }); return { ...workspace, + existsOnDisk: queryState.existsOnDisk, + repairCommand: queryState.repairCommand, + repairMessage: queryState.repairMessage, + repairState: queryState.repairState, type: workspace.type as "worktree" | "branch", - worktreePath: getWorkspacePath(workspace) ?? "", + worktreePath: queryState.worktreePath, project: project ? { id: project.id, @@ -93,12 +168,16 @@ export const createQueryProcedures = () => { .sort((a, b) => a.tabOrder - b.tabOrder); }), - getAllGrouped: publicProcedure.query(() => { + getAllGrouped: publicProcedure.query(async () => { type WorkspaceItem = { id: string; projectId: string; sectionId: string | null; worktreeId: string | null; + existsOnDisk: boolean; + repairCommand: string | null; + repairMessage: string | null; + repairState: TrackedWorktreeRepairState; worktreePath: string; type: "worktree" | "branch"; branch: string; @@ -133,13 +212,7 @@ export const createQueryProcedures = () => { .where(isNotNull(projects.tabOrder)) .all(); - const allWorktrees = localDb.select().from(worktrees).all(); - const worktreePathMap: WorktreePathMap = new Map( - allWorktrees.map((wt) => [wt.id, wt.path]), - ); - const allSections = localDb.select().from(workspaceSections).all(); - const groupsMap = new Map< string, { @@ -161,15 +234,15 @@ export const createQueryProcedures = () => { for (const project of activeProjects) { const projectSections = allSections - .filter((s) => s.projectId === project.id) + .filter((section) => section.projectId === project.id) .sort((a, b) => a.tabOrder - b.tabOrder) - .map((s) => ({ - id: s.id, - projectId: s.projectId, - name: s.name, - tabOrder: s.tabOrder, - isCollapsed: s.isCollapsed ?? false, - color: s.color ?? null, + .map((section) => ({ + id: section.id, + projectId: section.projectId, + name: section.name, + tabOrder: section.tabOrder, + isCollapsed: section.isCollapsed ?? false, + color: section.color ?? null, workspaces: [] as WorkspaceItem[], })); @@ -197,29 +270,38 @@ export const createQueryProcedures = () => { .where(isNull(workspaces.deletingAt)) .all() .sort((a, b) => a.tabOrder - b.tabOrder); + const allWorktrees = localDb.select().from(worktrees).all(); + const worktreeMap = new Map( + allWorktrees.map((worktree) => [worktree.id, worktree]), + ); for (const workspace of allWorkspaces) { const group = groupsMap.get(workspace.projectId); if (group) { - let worktreePath = ""; - if (workspace.type === "worktree" && workspace.worktreeId) { - worktreePath = worktreePathMap.get(workspace.worktreeId) ?? ""; - } else if (workspace.type === "branch") { - worktreePath = group.project.mainRepoPath; - } - + const queryState = await getWorkspacePathForQuery({ + mode: "display", + projectMainRepoPath: group.project.mainRepoPath, + workspace, + worktree: workspace.worktreeId + ? (worktreeMap.get(workspace.worktreeId) ?? null) + : null, + }); const item: WorkspaceItem = { ...workspace, + existsOnDisk: queryState.existsOnDisk, + repairCommand: queryState.repairCommand, + repairMessage: queryState.repairMessage, + repairState: queryState.repairState, sectionId: workspace.sectionId ?? null, type: workspace.type as "worktree" | "branch", - worktreePath, + worktreePath: queryState.worktreePath, isUnread: workspace.isUnread ?? false, isUnnamed: workspace.isUnnamed ?? false, }; if (workspace.sectionId) { const section = group.sections.find( - (s) => s.id === workspace.sectionId, + (groupSection) => groupSection.id === workspace.sectionId, ); if (section) { section.workspaces.push(item); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts index 785ec815871..b1f34fa046c 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts @@ -7,7 +7,7 @@ import { workspaceSections, workspaces, worktrees, -} from "@superset/local-db"; +} from "@superset/local-db/schema"; import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.test.ts new file mode 100644 index 00000000000..4df576baba4 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.test.ts @@ -0,0 +1,226 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + SelectWorkspace, + SelectWorktree, +} from "@superset/local-db/schema"; +import { + __testOnlyExternalWorktreeDeps, + listImportableExternalWorktrees, + resolveExternalWorktreeOpenTarget, +} from "./external-worktrees"; +import type { ExternalWorktree } from "./git"; + +function makeWorktree(overrides: Partial = {}): SelectWorktree { + return { + id: "wt-1", + projectId: "proj-1", + path: "/repo/wt-new", + branch: "feat-move", + baseBranch: "main", + createdAt: 1, + gitStatus: null, + githubStatus: null, + ...overrides, + }; +} + +function makeWorkspace( + overrides: Partial = {}, +): SelectWorkspace { + return { + id: "ws-1", + projectId: "proj-1", + worktreeId: "wt-1", + type: "worktree", + branch: "feat-move", + name: "feat-move", + tabOrder: 0, + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1, + isUnread: false, + isUnnamed: false, + deletingAt: null, + portBase: null, + sectionId: null, + ...overrides, + }; +} + +const originalDeps = { + ...__testOnlyExternalWorktreeDeps, +}; + +const findProjectWorktreeByCurrentPathMock = mock< + typeof __testOnlyExternalWorktreeDeps.findProjectWorktreeByCurrentPath +>(async () => null); +const findWorktreeWorkspaceByBranchMock = mock< + typeof __testOnlyExternalWorktreeDeps.findWorktreeWorkspaceByBranch +>(() => null); +const findOrphanedWorktreeByBranchMock = mock< + typeof __testOnlyExternalWorktreeDeps.findOrphanedWorktreeByBranch +>(() => null); +const listExternalWorktreesMock = mock< + typeof __testOnlyExternalWorktreeDeps.listExternalWorktrees +>(async () => []); +const listProjectWorktreesWithCurrentPathsMock = mock< + typeof __testOnlyExternalWorktreeDeps.listProjectWorktreesWithCurrentPaths +>(async () => []); +const resolveWorktreePathWithRepairMock = mock< + typeof __testOnlyExternalWorktreeDeps.resolveWorktreePathWithRepair +>(async () => null); + +describe("external-worktrees", () => { + beforeEach(() => { + findProjectWorktreeByCurrentPathMock.mockReset(); + findWorktreeWorkspaceByBranchMock.mockReset(); + findOrphanedWorktreeByBranchMock.mockReset(); + listExternalWorktreesMock.mockReset(); + listProjectWorktreesWithCurrentPathsMock.mockReset(); + resolveWorktreePathWithRepairMock.mockReset(); + + findProjectWorktreeByCurrentPathMock.mockResolvedValue(null); + findWorktreeWorkspaceByBranchMock.mockReturnValue(null); + findOrphanedWorktreeByBranchMock.mockReturnValue(null); + listExternalWorktreesMock.mockResolvedValue([]); + listProjectWorktreesWithCurrentPathsMock.mockResolvedValue([]); + resolveWorktreePathWithRepairMock.mockResolvedValue(null); + + __testOnlyExternalWorktreeDeps.findProjectWorktreeByCurrentPath = ( + ...args + ) => findProjectWorktreeByCurrentPathMock(...args); + __testOnlyExternalWorktreeDeps.findWorktreeWorkspaceByBranch = (...args) => + findWorktreeWorkspaceByBranchMock(...args); + __testOnlyExternalWorktreeDeps.findOrphanedWorktreeByBranch = (...args) => + findOrphanedWorktreeByBranchMock(...args); + __testOnlyExternalWorktreeDeps.listExternalWorktrees = (...args) => + listExternalWorktreesMock(...args); + __testOnlyExternalWorktreeDeps.listProjectWorktreesWithCurrentPaths = ( + ...args + ) => listProjectWorktreesWithCurrentPathsMock(...args); + __testOnlyExternalWorktreeDeps.resolveWorktreePathWithRepair = (...args) => + resolveWorktreePathWithRepairMock(...args); + }); + + afterAll(() => { + Object.assign(__testOnlyExternalWorktreeDeps, originalDeps); + }); + + test("reuses a tracked worktree by branch when path repair changes the current path", async () => { + const trackedWorktree = makeWorktree({ + path: "/repo/wt-old", + }); + resolveWorktreePathWithRepairMock.mockResolvedValue("/repo/wt-new"); + + findWorktreeWorkspaceByBranchMock.mockReturnValue({ + workspace: makeWorkspace({ worktreeId: trackedWorktree.id }), + worktree: trackedWorktree, + }); + + const result = await resolveExternalWorktreeOpenTarget({ + projectId: "proj-1", + mainRepoPath: "/repo/main", + worktreePath: "/repo/wt-old", + branch: "feat-move", + }); + + expect(result).toEqual({ + kind: "tracked", + worktree: { + ...trackedWorktree, + path: "/repo/wt-new", + }, + }); + expect(listExternalWorktreesMock).not.toHaveBeenCalled(); + }); + + test("uses Git's current branch when importing by path from a stale request", async () => { + listExternalWorktreesMock.mockResolvedValue([ + { + path: "/repo/wt-new", + branch: "feat-move", + isDetached: false, + isBare: false, + }, + ] satisfies ExternalWorktree[]); + + const result = await resolveExternalWorktreeOpenTarget({ + projectId: "proj-1", + mainRepoPath: "/repo/main", + worktreePath: "/repo/wt-new", + branch: "feat-stale", + }); + + expect(result).toEqual({ + kind: "external", + worktreePath: "/repo/wt-new", + branch: "feat-move", + }); + }); + + test("repairs tracked worktrees before reading Git's external worktree list", async () => { + const callOrder: string[] = []; + let listedPath = "/repo/wt-old"; + + listProjectWorktreesWithCurrentPathsMock.mockImplementation(async () => { + callOrder.push("tracked"); + listedPath = "/repo/wt-new"; + return [ + { + worktree: makeWorktree({ + path: "/repo/wt-new", + }), + existsOnDisk: true, + }, + ]; + }); + + listExternalWorktreesMock.mockImplementation(async () => { + callOrder.push("external"); + return [ + { + path: listedPath, + branch: "feat-move", + isDetached: false, + isBare: false, + }, + ]; + }); + + const result = await listImportableExternalWorktrees({ + projectId: "proj-1", + mainRepoPath: "/repo/main", + }); + + expect(result).toEqual([]); + expect(callOrder).toEqual(["tracked", "external"]); + }); + + test("keeps already-tracked branches out of the external import list even when the tracked entry is currently missing on disk", async () => { + listProjectWorktreesWithCurrentPathsMock.mockResolvedValue([ + { + worktree: makeWorktree({ + path: "/repo/wt-stale", + branch: "feat-move", + }), + existsOnDisk: false, + }, + ]); + + listExternalWorktreesMock.mockResolvedValue([ + { + path: "/repo/wt-current", + branch: "feat-move", + isDetached: false, + isBare: false, + }, + ] satisfies ExternalWorktree[]); + + const result = await listImportableExternalWorktrees({ + projectId: "proj-1", + mainRepoPath: "/repo/main", + }); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts new file mode 100644 index 00000000000..66b668a237b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts @@ -0,0 +1,169 @@ +import type { SelectWorktree } from "@superset/local-db/schema"; +import { + findOrphanedWorktreeByBranch, + findWorktreeWorkspaceByBranch, +} from "./db-helpers"; +import { type ExternalWorktree, listExternalWorktrees } from "./git"; +import { + findProjectWorktreeByCurrentPath, + listProjectWorktreesWithCurrentPaths, + resolveWorktreePathWithRepair, +} from "./repair-worktree-path"; + +interface ExternalWorktreeDeps { + findOrphanedWorktreeByBranch: typeof findOrphanedWorktreeByBranch; + findProjectWorktreeByCurrentPath: typeof findProjectWorktreeByCurrentPath; + findWorktreeWorkspaceByBranch: typeof findWorktreeWorkspaceByBranch; + listExternalWorktrees: typeof listExternalWorktrees; + listProjectWorktreesWithCurrentPaths: typeof listProjectWorktreesWithCurrentPaths; + resolveWorktreePathWithRepair: typeof resolveWorktreePathWithRepair; +} + +export const __testOnlyExternalWorktreeDeps: ExternalWorktreeDeps = { + findOrphanedWorktreeByBranch, + findProjectWorktreeByCurrentPath, + findWorktreeWorkspaceByBranch, + listExternalWorktrees, + listProjectWorktreesWithCurrentPaths, + resolveWorktreePathWithRepair, +}; + +export type ExternalWorktreeOpenTarget = + | { + kind: "tracked"; + worktree: SelectWorktree; + } + | { + kind: "external"; + worktreePath: string; + branch: string; + }; + +type ImportableExternalWorktree = ExternalWorktree & { branch: string }; + +function isImportableExternalWorktree( + worktree: ExternalWorktree, + mainRepoPath: string, +): worktree is ImportableExternalWorktree { + return ( + worktree.path !== mainRepoPath && + !worktree.isBare && + !worktree.isDetached && + Boolean(worktree.branch) + ); +} + +function getImportableExternalWorktrees( + externalWorktrees: ExternalWorktree[], + mainRepoPath: string, +): ImportableExternalWorktree[] { + return externalWorktrees.filter( + (worktree): worktree is ImportableExternalWorktree => + isImportableExternalWorktree(worktree, mainRepoPath), + ); +} + +async function resolveTrackedExternalWorktree( + worktree: SelectWorktree, +): Promise { + const resolvedPath = + await __testOnlyExternalWorktreeDeps.resolveWorktreePathWithRepair( + worktree.id, + ); + + if (!resolvedPath || resolvedPath === worktree.path) { + return worktree; + } + + return { + ...worktree, + path: resolvedPath, + }; +} + +export async function resolveExternalWorktreeOpenTarget(input: { + projectId: string; + mainRepoPath: string; + worktreePath: string; + branch: string; +}): Promise { + const trackedWorktree = + (await __testOnlyExternalWorktreeDeps.findProjectWorktreeByCurrentPath( + input.projectId, + input.worktreePath, + )) ?? + __testOnlyExternalWorktreeDeps.findWorktreeWorkspaceByBranch({ + projectId: input.projectId, + branch: input.branch, + })?.worktree ?? + __testOnlyExternalWorktreeDeps.findOrphanedWorktreeByBranch({ + projectId: input.projectId, + branch: input.branch, + }); + + if (trackedWorktree) { + return { + kind: "tracked", + worktree: await resolveTrackedExternalWorktree(trackedWorktree), + }; + } + + const externalWorktrees = + await __testOnlyExternalWorktreeDeps.listExternalWorktrees( + input.mainRepoPath, + ); + const matchingExternalWorktree = getImportableExternalWorktrees( + externalWorktrees, + input.mainRepoPath, + ).find( + (worktree) => + worktree.path === input.worktreePath || worktree.branch === input.branch, + ); + + if (!matchingExternalWorktree) { + return null; + } + + return { + kind: "external", + worktreePath: matchingExternalWorktree.path, + branch: matchingExternalWorktree.branch, + }; +} + +export async function listImportableExternalWorktrees(input: { + projectId: string; + mainRepoPath: string; +}): Promise< + Array<{ + path: string; + branch: string; + }> +> { + const trackedWorktrees = + await __testOnlyExternalWorktreeDeps.listProjectWorktreesWithCurrentPaths( + input.projectId, + ); + const trackedPaths = new Set( + trackedWorktrees.map((trackedWorktree) => trackedWorktree.worktree.path), + ); + const trackedBranches = new Set( + trackedWorktrees.map((trackedWorktree) => trackedWorktree.worktree.branch), + ); + + const externalWorktrees = + await __testOnlyExternalWorktreeDeps.listExternalWorktrees( + input.mainRepoPath, + ); + + return getImportableExternalWorktrees(externalWorktrees, input.mainRepoPath) + .filter( + (worktree) => + !trackedPaths.has(worktree.path) && + !trackedBranches.has(worktree.branch), + ) + .map((worktree) => ({ + path: worktree.path, + branch: worktree.branch, + })); +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 17ece16c6b7..ab5613c2387 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -829,6 +829,22 @@ export async function getBranchWorktreePath({ } } +export async function repairWorktreeRegistration({ + mainRepoPath, + worktreePath, +}: { + mainRepoPath: string; + worktreePath: string; +}): Promise { + try { + const git = await getSimpleGitWithShellPath(mainRepoPath); + await git.raw(["worktree", "repair", worktreePath]); + } catch (error) { + console.error(`Failed to repair worktree registration: ${error}`); + throw error; + } +} + export async function hasOriginRemote(mainRepoPath: string): Promise { try { const git = await getSimpleGitWithShellPath(mainRepoPath); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.test.ts new file mode 100644 index 00000000000..e682c393a14 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.test.ts @@ -0,0 +1,571 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + __testOnlyRepairWorktreePathDeps, + findProjectWorktreeByCurrentPath, + getTrackedWorktreeDisplayState, + getTrackedWorktreeRepairCommand, + listProjectWorktreesWithCurrentPaths, + resolveTrackedWorktreePath, + resolveWorktreePathOrThrow, + resolveWorktreePathWithRepair, + resolveWorktreePathWithRepairMetadata, + tryRepairWorktreePath, +} from "./repair-worktree-path"; + +// --------------------------------------------------------------------------- +// Test helpers – real git repos on disk +// --------------------------------------------------------------------------- + +const TEST_DIR = join( + realpathSync(tmpdir()), + `superset-test-repair-${process.pid}`, +); +const EXTERNAL_TEST_DIR = join( + realpathSync(tmpdir()), + `superset-external-repair-${process.pid}`, +); + +function createTestRepo(name: string): string { + const repoPath = join(TEST_DIR, name); + mkdirSync(repoPath, { recursive: true }); + execSync("git init", { cwd: repoPath, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { + cwd: repoPath, + stdio: "ignore", + }); + execSync('git config user.name "Test"', { cwd: repoPath, stdio: "ignore" }); + return repoPath; +} + +function seedCommit(repoPath: string): void { + writeFileSync(join(repoPath, "README.md"), "# test\n"); + execSync("git add .", { cwd: repoPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: repoPath, stdio: "ignore" }); +} + +// --------------------------------------------------------------------------- +// DB mock – thin in-memory store +// --------------------------------------------------------------------------- + +interface MockWorktree { + id: string; + path: string; + branch: string; + projectId: string; +} + +interface MockProject { + id: string; + mainRepoPath: string; +} + +let mockWorktrees: Map; +let mockProjects: Map; + +const originalDeps = { + ...__testOnlyRepairWorktreePathDeps, +}; + +const WORKTREES_TABLE = { + id: Symbol("worktrees.id"), + projectId: Symbol("worktrees.projectId"), +}; +const PROJECTS_TABLE = { + id: Symbol("projects.id"), +}; + +const mockLocalDb = { + select: () => ({ + from: (table: typeof WORKTREES_TABLE | typeof PROJECTS_TABLE) => ({ + where: (value: string) => ({ + get: () => { + if (table === WORKTREES_TABLE) return mockWorktrees.get(value); + if (table === PROJECTS_TABLE) return mockProjects.get(value); + return undefined; + }, + all: () => { + if (table === WORKTREES_TABLE) { + return Array.from(mockWorktrees.values()).filter( + (worktree) => worktree.projectId === value, + ); + } + if (table === PROJECTS_TABLE) { + return Array.from(mockProjects.values()).filter( + (project) => project.id === value, + ); + } + return []; + }, + }), + all: () => { + if (table === WORKTREES_TABLE) { + return Array.from(mockWorktrees.values()); + } + if (table === PROJECTS_TABLE) { + return Array.from(mockProjects.values()); + } + return []; + }, + }), + }), + update: (_table: typeof WORKTREES_TABLE) => ({ + set: (values: { path?: string }) => ({ + where: (id: string) => ({ + run: () => { + const wt = mockWorktrees.get(id); + if (wt && values.path) wt.path = values.path; + }, + }), + }), + }), +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("repair-worktree-path", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + mockWorktrees = new Map(); + mockProjects = new Map(); + __testOnlyRepairWorktreePathDeps.eq = ((_: unknown, value: string) => + value) as unknown as typeof __testOnlyRepairWorktreePathDeps.eq; + __testOnlyRepairWorktreePathDeps.localDb = + mockLocalDb as unknown as typeof __testOnlyRepairWorktreePathDeps.localDb; + __testOnlyRepairWorktreePathDeps.projects = + PROJECTS_TABLE as unknown as typeof __testOnlyRepairWorktreePathDeps.projects; + __testOnlyRepairWorktreePathDeps.worktrees = + WORKTREES_TABLE as unknown as typeof __testOnlyRepairWorktreePathDeps.worktrees; + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + if (existsSync(EXTERNAL_TEST_DIR)) { + rmSync(EXTERNAL_TEST_DIR, { recursive: true, force: true }); + } + }); + + afterAll(() => { + Object.assign(__testOnlyRepairWorktreePathDeps, originalDeps); + }); + + test("returns null when worktree record is missing", async () => { + expect(await tryRepairWorktreePath("nonexistent")).toBeNull(); + }); + + test("returns existing path when it is still valid on disk", async () => { + const mainRepo = createTestRepo("main-valid"); + seedCommit(mainRepo); + + const wtPath = join(TEST_DIR, "wt-valid"); + execSync( + `git -C "${mainRepo}" worktree add "${wtPath}" -b feat-valid HEAD`, + { stdio: "ignore" }, + ); + + mockWorktrees.set("wt-1", { + id: "wt-1", + path: wtPath, + branch: "feat-valid", + projectId: "proj-1", + }); + mockProjects.set("proj-1", { id: "proj-1", mainRepoPath: mainRepo }); + + const result = await tryRepairWorktreePath("wt-1"); + expect(result).toBe(wtPath); + }); + + test("resolveWorktreePathWithRepair returns existing path without repair", async () => { + const mainRepo = createTestRepo("main-resolve-valid"); + seedCommit(mainRepo); + + const wtPath = join(TEST_DIR, "wt-resolve-valid"); + execSync( + `git -C "${mainRepo}" worktree add "${wtPath}" -b feat-resolve-valid HEAD`, + { stdio: "ignore" }, + ); + + mockWorktrees.set("wt-resolve-1", { + id: "wt-resolve-1", + path: wtPath, + branch: "feat-resolve-valid", + projectId: "proj-resolve-1", + }); + mockProjects.set("proj-resolve-1", { + id: "proj-resolve-1", + mainRepoPath: mainRepo, + }); + + const result = await resolveWorktreePathWithRepair("wt-resolve-1"); + expect(result).toBe(wtPath); + }); + + test("repairs path after `git worktree move`", async () => { + const mainRepo = createTestRepo("main-move"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-old"); + const newPath = join(TEST_DIR, "wt-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-move HEAD`, + { stdio: "ignore" }, + ); + execSync(`git -C "${mainRepo}" worktree move "${oldPath}" "${newPath}"`, { + stdio: "ignore", + }); + + mockWorktrees.set("wt-2", { + id: "wt-2", + path: oldPath, // stale + branch: "feat-move", + projectId: "proj-2", + }); + mockProjects.set("proj-2", { id: "proj-2", mainRepoPath: mainRepo }); + + const result = await tryRepairWorktreePath("wt-2"); + expect(result).toBe(newPath); + // DB should also be updated + expect(mockWorktrees.get("wt-2")?.path).toBe(newPath); + }); + + test("resolveWorktreePathWithRepair returns repaired path after move", async () => { + const mainRepo = createTestRepo("main-resolve-move"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-resolve-old"); + const newPath = join(TEST_DIR, "wt-resolve-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-resolve-move HEAD`, + { stdio: "ignore" }, + ); + execSync(`git -C "${mainRepo}" worktree move "${oldPath}" "${newPath}"`, { + stdio: "ignore", + }); + + mockWorktrees.set("wt-resolve-2", { + id: "wt-resolve-2", + path: oldPath, + branch: "feat-resolve-move", + projectId: "proj-resolve-2", + }); + mockProjects.set("proj-resolve-2", { + id: "proj-resolve-2", + mainRepoPath: mainRepo, + }); + + const result = await resolveWorktreePathWithRepair("wt-resolve-2"); + expect(result).toBe(newPath); + expect(mockWorktrees.get("wt-resolve-2")?.path).toBe(newPath); + }); + + test("resolveWorktreePathWithRepairMetadata reports when a path changed", async () => { + const mainRepo = createTestRepo("main-resolve-meta"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-resolve-meta-old"); + const newPath = join(TEST_DIR, "wt-resolve-meta-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-resolve-meta HEAD`, + { stdio: "ignore" }, + ); + execSync(`git -C "${mainRepo}" worktree move "${oldPath}" "${newPath}"`, { + stdio: "ignore", + }); + + mockWorktrees.set("wt-resolve-meta-1", { + id: "wt-resolve-meta-1", + path: oldPath, + branch: "feat-resolve-meta", + projectId: "proj-resolve-meta-1", + }); + mockProjects.set("proj-resolve-meta-1", { + id: "proj-resolve-meta-1", + mainRepoPath: mainRepo, + }); + + const result = + await resolveWorktreePathWithRepairMetadata("wt-resolve-meta-1"); + expect(result.path).toBe(newPath); + expect(result.pathChanged).toBe(true); + expect(result.repairState).toBe("ok"); + expect(mockWorktrees.get("wt-resolve-meta-1")?.path).toBe(newPath); + }); + + test("listProjectWorktreesWithCurrentPaths returns repaired paths for moved worktrees", async () => { + const mainRepo = createTestRepo("main-list-project"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-list-old"); + const newPath = join(TEST_DIR, "wt-list-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-list-project HEAD`, + { stdio: "ignore" }, + ); + execSync(`git -C "${mainRepo}" worktree move "${oldPath}" "${newPath}"`, { + stdio: "ignore", + }); + + mockWorktrees.set("wt-list-1", { + id: "wt-list-1", + path: oldPath, + branch: "feat-list-project", + projectId: "proj-list-1", + }); + mockProjects.set("proj-list-1", { + id: "proj-list-1", + mainRepoPath: mainRepo, + }); + + const result = await listProjectWorktreesWithCurrentPaths("proj-list-1"); + expect(result).toHaveLength(1); + expect(result[0]?.existsOnDisk).toBe(true); + expect(result[0]?.worktree.path).toBe(newPath); + expect(mockWorktrees.get("wt-list-1")?.path).toBe(newPath); + }); + + test("findProjectWorktreeByCurrentPath matches repaired worktree paths", async () => { + const mainRepo = createTestRepo("main-find-project"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-find-old"); + const newPath = join(TEST_DIR, "wt-find-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-find-project HEAD`, + { stdio: "ignore" }, + ); + execSync(`git -C "${mainRepo}" worktree move "${oldPath}" "${newPath}"`, { + stdio: "ignore", + }); + + mockWorktrees.set("wt-find-1", { + id: "wt-find-1", + path: oldPath, + branch: "feat-find-project", + projectId: "proj-find-1", + }); + mockProjects.set("proj-find-1", { + id: "proj-find-1", + mainRepoPath: mainRepo, + }); + + const result = await findProjectWorktreeByCurrentPath( + "proj-find-1", + newPath, + ); + expect(result?.id).toBe("wt-find-1"); + expect(result?.path).toBe(newPath); + expect(mockWorktrees.get("wt-find-1")?.path).toBe(newPath); + }); + + test("rejects candidate when it equals the main repo path", async () => { + const mainRepo = createTestRepo("main-reject"); + seedCommit(mainRepo); + + // Derive the actual default branch so the test exercises the guard + // regardless of whether `git init` defaults to "main" or "master". + const defaultBranch = execSync( + `git -C "${mainRepo}" rev-parse --abbrev-ref HEAD`, + { + encoding: "utf-8", + }, + ).trim(); + + const stalePath = join(TEST_DIR, "wt-gone"); + + mockWorktrees.set("wt-3", { + id: "wt-3", + path: stalePath, + branch: defaultBranch, + projectId: "proj-3", + }); + mockProjects.set("proj-3", { id: "proj-3", mainRepoPath: mainRepo }); + + const result = await tryRepairWorktreePath("wt-3"); + expect(result).toBeNull(); + // DB should NOT have been updated + expect(mockWorktrees.get("wt-3")?.path).toBe(stalePath); + }); + + test("returns null when project record is missing", async () => { + mockWorktrees.set("wt-4", { + id: "wt-4", + path: "/nonexistent/path", + branch: "feat-orphan", + projectId: "proj-missing", + }); + + expect(await tryRepairWorktreePath("wt-4")).toBeNull(); + }); + + test("returns null when worktree is not found by git", async () => { + const mainRepo = createTestRepo("main-notfound"); + seedCommit(mainRepo); + + mockWorktrees.set("wt-5", { + id: "wt-5", + path: "/nonexistent/path", + branch: "feat-does-not-exist", + projectId: "proj-5", + }); + mockProjects.set("proj-5", { id: "proj-5", mainRepoPath: mainRepo }); + + const result = await tryRepairWorktreePath("wt-5"); + expect(result).toBeNull(); + }); + + test("resolveWorktreePathWithRepair returns null when missing path cannot be repaired", async () => { + const mainRepo = createTestRepo("main-resolve-missing"); + seedCommit(mainRepo); + + mockWorktrees.set("wt-resolve-3", { + id: "wt-resolve-3", + path: "/nonexistent/path", + branch: "feat-missing", + projectId: "proj-resolve-3", + }); + mockProjects.set("proj-resolve-3", { + id: "proj-resolve-3", + mainRepoPath: mainRepo, + }); + + const result = await resolveWorktreePathWithRepair("wt-resolve-3"); + expect(result).toBeNull(); + }); + + test("resolveTrackedWorktreePath auto-repairs a nearby manual rename", async () => { + const mainRepo = createTestRepo("main-manual-rename"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-manual-old"); + const newPath = join(TEST_DIR, "wt-manual-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-manual-rename HEAD`, + { stdio: "ignore" }, + ); + execSync(`mv "${oldPath}" "${newPath}"`, { stdio: "ignore" }); + + mockWorktrees.set("wt-manual-1", { + id: "wt-manual-1", + path: oldPath, + branch: "feat-manual-rename", + projectId: "proj-manual-1", + }); + mockProjects.set("proj-manual-1", { + id: "proj-manual-1", + mainRepoPath: mainRepo, + }); + + const result = await resolveTrackedWorktreePath("wt-manual-1"); + expect(result).toEqual({ + status: "resolved", + path: newPath, + }); + expect(mockWorktrees.get("wt-manual-1")?.path).toBe(newPath); + expect( + execSync(`git -C "${mainRepo}" worktree list --porcelain`, { + encoding: "utf-8", + }), + ).toContain(newPath); + }); + + test("resolveWorktreePathOrThrow tells users to run git worktree repair when auto-repair cannot find the moved worktree", async () => { + const mainRepo = createTestRepo("main-manual-rename-throw"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-manual-throw-old"); + const externalDir = join(EXTERNAL_TEST_DIR, "level-1", "level-2"); + mkdirSync(externalDir, { recursive: true }); + const newPath = join(externalDir, "wt-manual-throw-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-manual-throw HEAD`, + { stdio: "ignore" }, + ); + execSync(`mv "${oldPath}" "${newPath}"`, { stdio: "ignore" }); + + mockWorktrees.set("wt-manual-2", { + id: "wt-manual-2", + path: oldPath, + branch: "feat-manual-throw", + projectId: "proj-manual-2", + }); + mockProjects.set("proj-manual-2", { + id: "proj-manual-2", + mainRepoPath: mainRepo, + }); + + await expect(resolveWorktreePathOrThrow("wt-manual-2")).rejects.toThrow( + getTrackedWorktreeRepairCommand(mainRepo), + ); + + const displayState = getTrackedWorktreeDisplayState("wt-manual-2"); + expect(displayState.repairState).toBe("repair_required"); + expect(displayState.worktreePath).toBeNull(); + expect(displayState.repairCommand).toBe( + getTrackedWorktreeRepairCommand(mainRepo), + ); + }); + + test("skips repeated auto-repair attempts during backoff after a failed repair", async () => { + const mainRepo = createTestRepo("main-repair-backoff"); + seedCommit(mainRepo); + + const oldPath = join(TEST_DIR, "wt-backoff-old"); + const newPath = join(TEST_DIR, "wt-backoff-new"); + execSync( + `git -C "${mainRepo}" worktree add "${oldPath}" -b feat-backoff HEAD`, + { stdio: "ignore" }, + ); + execSync(`mv "${oldPath}" "${newPath}"`, { stdio: "ignore" }); + + mockWorktrees.set("wt-backoff-1", { + id: "wt-backoff-1", + path: oldPath, + branch: "feat-backoff", + projectId: "proj-backoff-1", + }); + mockProjects.set("proj-backoff-1", { + id: "proj-backoff-1", + mainRepoPath: mainRepo, + }); + + const originalRepair = + __testOnlyRepairWorktreePathDeps.repairWorktreeRegistration; + let repairAttempts = 0; + __testOnlyRepairWorktreePathDeps.repairWorktreeRegistration = async () => { + repairAttempts += 1; + throw new Error("repair failed"); + }; + + try { + const first = await resolveTrackedWorktreePath("wt-backoff-1"); + const second = await resolveTrackedWorktreePath("wt-backoff-1"); + + expect(first.status).toBe("git_repair_required"); + expect(second.status).toBe("git_repair_required"); + expect(repairAttempts).toBe(1); + } finally { + __testOnlyRepairWorktreePathDeps.repairWorktreeRegistration = + originalRepair; + } + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts new file mode 100644 index 00000000000..6681eb4dca3 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts @@ -0,0 +1,904 @@ +import { + type Dirent, + existsSync, + readdirSync, + readFileSync, + realpathSync, + statSync, +} from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { + projects, + type SelectWorktree, + worktrees, +} from "@superset/local-db/schema"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { getBranchWorktreePath, repairWorktreeRegistration } from "./git"; + +export type ResolveTrackedWorktreePathResult = + | { + status: "resolved"; + path: string; + } + | { + status: "git_repair_required"; + branch: string; + mainRepoPath: string; + registeredPath: string; + storedPath: string; + } + | { + status: "missing"; + }; + +export type TrackedWorktreeRepairState = + | "ok" + | "missing" + | "repair_required" + | "repairing"; + +export interface TrackedWorktreeDisplayState { + existsOnDisk: boolean; + repairCommand: string | null; + repairMessage: string | null; + repairState: TrackedWorktreeRepairState; + storedPath: string; + worktreePath: string | null; +} + +interface TrackedWorktreeContext { + mainRepoPath: string; + worktree: SelectWorktree; +} + +interface RepairWorktreePathDeps { + eq: typeof eq; + getBranchWorktreePath: typeof getBranchWorktreePath; + localDb: typeof localDb; + projects: typeof projects; + repairWorktreeRegistration: typeof repairWorktreeRegistration; + worktrees: typeof worktrees; +} + +interface ResolveTrackedWorktreePathWithMetadataResult { + pathChanged: boolean; + resolution: ResolveTrackedWorktreePathResult; +} + +interface CachedRepairFailure { + recordedAt: number; + resolution: Exclude; +} + +export const __testOnlyRepairWorktreePathDeps: RepairWorktreePathDeps = { + eq, + getBranchWorktreePath, + localDb, + projects, + repairWorktreeRegistration, + worktrees, +}; + +function buildResolvedResult(path: string): ResolveTrackedWorktreePathResult { + return { + status: "resolved", + path, + }; +} + +function buildMissingResolutionResult(): Extract< + ResolveTrackedWorktreePathResult, + { status: "missing" } +> { + return { status: "missing" }; +} + +function buildGitRepairRequiredResolution( + context: TrackedWorktreeContext, + registeredPath: string, +): Extract< + ResolveTrackedWorktreePathResult, + { status: "git_repair_required" } +> { + return { + status: "git_repair_required", + branch: context.worktree.branch, + mainRepoPath: context.mainRepoPath, + registeredPath, + storedPath: context.worktree.path, + }; +} + +const MAX_SEARCH_DEPTH = 1; +const MAX_SCAN_DIRS = 250; +const AUTO_REPAIR_BACKOFF_MS = 30_000; +const SKIPPED_SCAN_DIRS = new Set([ + ".git", + "node_modules", + ".next", + "dist", + "build", + "coverage", + "target", +]); +const cachedRepairFailures = new Map(); +const activeRepairAttempts = new Map< + string, + Promise +>(); + +function readDirectoryEntries(path: string): Dirent[] { + return readdirSync(path, { withFileTypes: true }); +} + +function safeResolvePath(path: string): string { + return resolve(path); +} + +function safeRealpath(path: string): string { + try { + return realpathSync(path); + } catch { + return safeResolvePath(path); + } +} + +function isExistingDirectory(path: string): boolean { + if (!existsSync(path)) { + return false; + } + + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function parseGitdirReference(worktreePath: string): string | null { + const dotGitPath = join(worktreePath, ".git"); + if (!existsSync(dotGitPath)) { + return null; + } + + try { + if (!statSync(dotGitPath).isFile()) { + return null; + } + + const contents = readFileSync(dotGitPath, "utf8").trim(); + if (!contents.startsWith("gitdir:")) { + return null; + } + + const rawGitdir = contents.slice("gitdir:".length).trim(); + return isAbsolute(rawGitdir) + ? safeResolvePath(rawGitdir) + : safeResolvePath(resolve(worktreePath, rawGitdir)); + } catch { + return null; + } +} + +function getTrackedWorktreeSearchRoots( + context: TrackedWorktreeContext, +): string[] { + const roots = [ + dirname(context.worktree.path), + context.mainRepoPath, + dirname(context.mainRepoPath), + ]; + + const seen = new Set(); + const result: string[] = []; + + for (const root of roots) { + if (!isExistingDirectory(root)) { + continue; + } + + const normalizedRoot = safeRealpath(root); + if (seen.has(normalizedRoot)) { + continue; + } + + seen.add(normalizedRoot); + result.push(normalizedRoot); + } + + return result; +} + +function findTrackedWorktreeMetadata(input: { + context: TrackedWorktreeContext; +}): { + metadataDir: string; + registeredPath: string; +} | null { + const metadataRoot = join(input.context.mainRepoPath, ".git", "worktrees"); + if (!isExistingDirectory(metadataRoot)) { + return null; + } + + const expectedStoredPath = safeResolvePath(input.context.worktree.path); + + for (const entry of readDirectoryEntries(metadataRoot)) { + if (!entry.isDirectory()) { + continue; + } + + const metadataDir = join(metadataRoot, entry.name); + const headPath = join(metadataDir, "HEAD"); + const gitdirPath = join(metadataDir, "gitdir"); + + if (!existsSync(headPath) || !existsSync(gitdirPath)) { + continue; + } + + try { + const head = readFileSync(headPath, "utf8").trim(); + const rawGitdir = readFileSync(gitdirPath, "utf8").trim(); + const registeredGitdir = isAbsolute(rawGitdir) + ? safeResolvePath(rawGitdir) + : safeResolvePath(resolve(metadataDir, rawGitdir)); + const registeredPath = dirname(registeredGitdir); + + if ( + head === `ref: refs/heads/${input.context.worktree.branch}` || + safeResolvePath(registeredPath) === expectedStoredPath + ) { + return { metadataDir, registeredPath }; + } + } catch {} + } + + return null; +} + +function findMovedTrackedWorktreeCandidate(input: { + context: TrackedWorktreeContext; + metadataDir: string; +}): string | null { + const expectedMetadataDir = safeRealpath(input.metadataDir); + const mainRepoRealPath = safeRealpath(input.context.mainRepoPath); + const searchRoots = getTrackedWorktreeSearchRoots(input.context); + const visited = new Set(); + const stack = searchRoots.map((path) => ({ path, depth: 0 })); + let scannedDirs = 0; + + while (stack.length > 0 && scannedDirs < MAX_SCAN_DIRS) { + const current = stack.pop(); + if (!current) { + continue; + } + + if (!isExistingDirectory(current.path)) { + continue; + } + + const currentRealPath = safeRealpath(current.path); + if (visited.has(currentRealPath)) { + continue; + } + + visited.add(currentRealPath); + scannedDirs += 1; + + if (currentRealPath !== mainRepoRealPath) { + const gitdirReference = parseGitdirReference(current.path); + if ( + gitdirReference && + safeRealpath(gitdirReference) === expectedMetadataDir + ) { + return currentRealPath; + } + } + + if (current.depth >= MAX_SEARCH_DEPTH) { + continue; + } + + let entries: Dirent[]; + try { + entries = readDirectoryEntries(current.path); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + if (SKIPPED_SCAN_DIRS.has(entry.name)) { + continue; + } + + const childPath = join(current.path, entry.name); + if (safeRealpath(childPath) === mainRepoRealPath) { + continue; + } + + stack.push({ + path: childPath, + depth: current.depth + 1, + }); + } + } + + return null; +} + +async function tryAutoRepairTrackedWorktree(input: { + context: TrackedWorktreeContext; +}): Promise { + const metadata = findTrackedWorktreeMetadata({ + context: input.context, + }); + if (!metadata) { + return null; + } + + const candidatePath = findMovedTrackedWorktreeCandidate({ + context: input.context, + metadataDir: metadata.metadataDir, + }); + + if (!candidatePath) { + return null; + } + + console.log( + `[repair-worktree-path] Found manually moved worktree for branch ${input.context.worktree.branch} at "${candidatePath}", repairing Git registration`, + ); + try { + await __testOnlyRepairWorktreePathDeps.repairWorktreeRegistration({ + mainRepoPath: input.context.mainRepoPath, + worktreePath: candidatePath, + }); + } catch (error) { + console.warn( + `[repair-worktree-path] Failed to repair Git registration for worktree ${input.context.worktree.id}:`, + error instanceof Error ? error.message : error, + ); + return null; + } + + let repairedPath: string | null = null; + try { + repairedPath = await __testOnlyRepairWorktreePathDeps.getBranchWorktreePath( + { + mainRepoPath: input.context.mainRepoPath, + branch: input.context.worktree.branch, + }, + ); + } catch (error) { + console.warn( + `[repair-worktree-path] Failed to refresh repaired path for worktree ${input.context.worktree.id}:`, + error instanceof Error ? error.message : error, + ); + } + + if (repairedPath && existsSync(repairedPath)) { + return repairedPath; + } + + return existsSync(candidatePath) ? candidatePath : null; +} + +function getCachedRepairFailure( + worktreeId: string, +): CachedRepairFailure | null { + const cached = cachedRepairFailures.get(worktreeId); + if (!cached) { + return null; + } + + if (Date.now() - cached.recordedAt > AUTO_REPAIR_BACKOFF_MS) { + cachedRepairFailures.delete(worktreeId); + return null; + } + + return cached; +} + +function rememberRepairFailure( + worktreeId: string, + resolution: Exclude, +): void { + cachedRepairFailures.set(worktreeId, { + recordedAt: Date.now(), + resolution, + }); +} + +function clearRepairFailure(worktreeId: string): void { + cachedRepairFailures.delete(worktreeId); +} + +export function getTrackedWorktreeRepairCommand(mainRepoPath: string): string { + return `git -C "${mainRepoPath}" worktree repair `; +} + +export function getTrackedWorktreeRepairMessage(input: { + branch: string; + mainRepoPath: string; +}): string { + return `Worktree branch "${input.branch}" was moved outside Git worktree management. Run ${getTrackedWorktreeRepairCommand(input.mainRepoPath)} with the current path, or use git worktree move next time.`; +} + +function buildTrackedWorktreeDisplayState( + context: TrackedWorktreeContext, +): TrackedWorktreeDisplayState { + if (existsSync(context.worktree.path)) { + clearRepairFailure(context.worktree.id); + return { + existsOnDisk: true, + repairCommand: null, + repairMessage: null, + repairState: "ok", + storedPath: context.worktree.path, + worktreePath: context.worktree.path, + }; + } + + if (activeRepairAttempts.has(context.worktree.id)) { + return { + existsOnDisk: false, + repairCommand: null, + repairMessage: "Repairing the moved worktree path in the background.", + repairState: "repairing", + storedPath: context.worktree.path, + worktreePath: null, + }; + } + + const cachedFailure = getCachedRepairFailure(context.worktree.id); + if (cachedFailure?.resolution.status === "git_repair_required") { + return { + existsOnDisk: false, + repairCommand: getTrackedWorktreeRepairCommand( + cachedFailure.resolution.mainRepoPath, + ), + repairMessage: getTrackedWorktreeRepairMessage({ + branch: cachedFailure.resolution.branch, + mainRepoPath: cachedFailure.resolution.mainRepoPath, + }), + repairState: "repair_required", + storedPath: context.worktree.path, + worktreePath: null, + }; + } + + return { + existsOnDisk: false, + repairCommand: null, + repairMessage: "Tracked worktree path is missing on disk.", + repairState: "missing", + storedPath: context.worktree.path, + worktreePath: null, + }; +} + +function getTrackedWorktreeContext( + worktreeId: string, +): TrackedWorktreeContext | null { + const worktree = __testOnlyRepairWorktreePathDeps.localDb + .select() + .from(__testOnlyRepairWorktreePathDeps.worktrees) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.worktrees.id, + worktreeId, + ), + ) + .get(); + + if (!worktree) { + return null; + } + + const project = __testOnlyRepairWorktreePathDeps.localDb + .select() + .from(__testOnlyRepairWorktreePathDeps.projects) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.projects.id, + worktree.projectId, + ), + ) + .get(); + + if (!project) { + return null; + } + + return { + mainRepoPath: project.mainRepoPath, + worktree, + }; +} + +export function getTrackedWorktreeDisplayStateFromTrackedWorktree(input: { + mainRepoPath: string; + worktree: SelectWorktree; +}): TrackedWorktreeDisplayState { + return buildTrackedWorktreeDisplayState({ + mainRepoPath: input.mainRepoPath, + worktree: input.worktree, + }); +} + +export function getTrackedWorktreeDisplayState( + worktreeId: string, +): TrackedWorktreeDisplayState { + const context = getTrackedWorktreeContext(worktreeId); + if (!context) { + return { + existsOnDisk: false, + repairCommand: null, + repairMessage: "Tracked worktree could not be found.", + repairState: "missing", + storedPath: "", + worktreePath: null, + }; + } + + return buildTrackedWorktreeDisplayState(context); +} + +function isMainRepoPath( + context: TrackedWorktreeContext, + candidatePath: string, +): boolean { + return safeRealpath(candidatePath) === safeRealpath(context.mainRepoPath); +} + +function persistResolvedTrackedWorktreePath(input: { + context: TrackedWorktreeContext; + resolvedPath: string; +}): ResolveTrackedWorktreePathWithMetadataResult { + if (isMainRepoPath(input.context, input.resolvedPath)) { + const resolution = buildMissingResolutionResult(); + rememberRepairFailure(input.context.worktree.id, resolution); + return { pathChanged: false, resolution }; + } + + const pathChanged = input.resolvedPath !== input.context.worktree.path; + + if (pathChanged) { + console.log( + `[repair-worktree-path] Worktree path changed: "${input.context.worktree.path}" → "${input.resolvedPath}" (branch: ${input.context.worktree.branch})`, + ); + __testOnlyRepairWorktreePathDeps.localDb + .update(__testOnlyRepairWorktreePathDeps.worktrees) + .set({ path: input.resolvedPath }) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.worktrees.id, + input.context.worktree.id, + ), + ) + .run(); + } + + clearRepairFailure(input.context.worktree.id); + + return { + pathChanged, + resolution: buildResolvedResult(input.resolvedPath), + }; +} + +async function getRegisteredTrackedWorktreePath( + context: TrackedWorktreeContext, +): Promise { + try { + return await __testOnlyRepairWorktreePathDeps.getBranchWorktreePath({ + mainRepoPath: context.mainRepoPath, + branch: context.worktree.branch, + }); + } catch (error) { + console.warn( + `[repair-worktree-path] Failed to inspect Git worktree state for ${context.worktree.id}:`, + error instanceof Error ? error.message : error, + ); + return null; + } +} + +async function resolveTrackedWorktreePathFromGitState( + context: TrackedWorktreeContext, +): Promise { + const registeredPath = await getRegisteredTrackedWorktreePath(context); + + if (!registeredPath) { + const resolution = buildMissingResolutionResult(); + rememberRepairFailure(context.worktree.id, resolution); + return { pathChanged: false, resolution }; + } + + if (existsSync(registeredPath)) { + return persistResolvedTrackedWorktreePath({ + context, + resolvedPath: registeredPath, + }); + } + + const cachedFailure = getCachedRepairFailure(context.worktree.id); + if (cachedFailure) { + return { + pathChanged: false, + resolution: cachedFailure.resolution, + }; + } + + const repairedPath = await tryAutoRepairTrackedWorktree({ + context, + }); + + if (!repairedPath) { + const resolution = buildGitRepairRequiredResolution( + context, + registeredPath, + ); + rememberRepairFailure(context.worktree.id, resolution); + return { pathChanged: false, resolution }; + } + + return persistResolvedTrackedWorktreePath({ + context, + resolvedPath: repairedPath, + }); +} + +async function resolveTrackedWorktreePathWithMetadata( + worktreeId: string, +): Promise { + const context = getTrackedWorktreeContext(worktreeId); + if (!context) { + return { + pathChanged: false, + resolution: buildMissingResolutionResult(), + }; + } + + if (existsSync(context.worktree.path)) { + clearRepairFailure(context.worktree.id); + return { + pathChanged: false, + resolution: buildResolvedResult(context.worktree.path), + }; + } + + const existingAttempt = activeRepairAttempts.get(worktreeId); + if (existingAttempt) { + return existingAttempt; + } + + const attempt = resolveTrackedWorktreePathFromGitState(context).finally( + () => { + activeRepairAttempts.delete(worktreeId); + }, + ); + activeRepairAttempts.set(worktreeId, attempt); + + return attempt; +} + +function getResolvedTrackedWorktreePath( + resolution: ResolveTrackedWorktreePathResult, +): string | null { + return resolution.status === "resolved" ? resolution.path : null; +} + +export async function resolveTrackedWorktreePath( + worktreeId: string, +): Promise { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + return resolution.resolution; +} + +export async function resolveWorktreePathOrThrow( + worktreeId: string, +): Promise { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + + if (resolution.resolution.status === "resolved") { + return resolution.resolution.path; + } + + if (resolution.resolution.status === "git_repair_required") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: getTrackedWorktreeRepairMessage({ + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + }), + cause: { + reason: "git_repair_required", + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + registeredPath: resolution.resolution.registeredPath, + storedPath: resolution.resolution.storedPath, + command: getTrackedWorktreeRepairCommand( + resolution.resolution.mainRepoPath, + ), + }, + }); + } + + return null; +} + +export async function resolveWorktreePathOrThrowWithMetadata( + worktreeId: string, +): Promise<{ + path: string | null; + pathChanged: boolean; +}> { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + + if (resolution.resolution.status === "resolved") { + return { + path: resolution.resolution.path, + pathChanged: resolution.pathChanged, + }; + } + + if (resolution.resolution.status === "git_repair_required") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: getTrackedWorktreeRepairMessage({ + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + }), + cause: { + reason: "git_repair_required", + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + registeredPath: resolution.resolution.registeredPath, + storedPath: resolution.resolution.storedPath, + command: getTrackedWorktreeRepairCommand( + resolution.resolution.mainRepoPath, + ), + }, + }); + } + + return { path: null, pathChanged: false }; +} + +/** + * Attempts to resolve a tracked worktree path, repairing stale Git registrations + * when possible. + * + * Handles: + * - normal `git worktree move` updates discovered from `git worktree list` + * - nearby manual renames that can be repaired via `git worktree repair` + * + * @returns The repaired path if successful, null otherwise + */ +export async function tryRepairWorktreePath( + worktreeId: string, +): Promise { + return resolveWorktreePathWithRepair(worktreeId); +} + +/** + * Returns the current usable worktree path for a tracked worktree. + * + * If the stored path still exists, it is returned unchanged. Otherwise this + * attempts the same branch-based repair flow used by terminal/git-status code. + */ +export async function resolveWorktreePathWithRepair( + worktreeId: string, +): Promise { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + return getResolvedTrackedWorktreePath(resolution.resolution); +} + +export async function resolveWorktreePathWithRepairMetadata( + worktreeId: string, +): Promise<{ + path: string | null; + pathChanged: boolean; + repairState: TrackedWorktreeRepairState; + repairMessage: string | null; + repairCommand: string | null; +}> { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + if (resolution.resolution.status === "resolved") { + return { + path: resolution.resolution.path, + pathChanged: resolution.pathChanged, + repairCommand: null, + repairMessage: null, + repairState: "ok", + }; + } + + const displayState = getTrackedWorktreeDisplayState(worktreeId); + return { + path: null, + pathChanged: false, + repairCommand: displayState.repairCommand, + repairMessage: displayState.repairMessage, + repairState: displayState.repairState, + }; +} + +export async function resolveTrackedWorktree( + worktree: SelectWorktree, +): Promise<{ + worktree: SelectWorktree; + existsOnDisk: boolean; +}> { + const resolvedPath = await resolveWorktreePathWithRepair(worktree.id); + + if (!resolvedPath) { + return { + worktree, + existsOnDisk: false, + }; + } + + if (resolvedPath === worktree.path) { + return { + worktree, + existsOnDisk: true, + }; + } + + return { + worktree: { + ...worktree, + path: resolvedPath, + }, + existsOnDisk: true, + }; +} + +export async function listProjectWorktreesWithCurrentPaths( + projectId: string, +): Promise< + Array<{ + worktree: SelectWorktree; + existsOnDisk: boolean; + }> +> { + const projectWorktrees = __testOnlyRepairWorktreePathDeps.localDb + .select() + .from(__testOnlyRepairWorktreePathDeps.worktrees) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.worktrees.projectId, + projectId, + ), + ) + .all(); + + return Promise.all(projectWorktrees.map(resolveTrackedWorktree)); +} + +export async function findProjectWorktreeByCurrentPath( + projectId: string, + worktreePath: string, +): Promise { + const trackedWorktrees = + await listProjectWorktreesWithCurrentPaths(projectId); + + for (const trackedWorktree of trackedWorktrees) { + if (!trackedWorktree.existsOnDisk) { + continue; + } + + if (trackedWorktree.worktree.path === worktreePath) { + return trackedWorktree.worktree; + } + } + + return null; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 8dd19f2100b..6f66569d038 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -237,8 +237,12 @@ export function ProjectSection({ item.kind === "workspace" ? ( ( 0 || pr.deletions > 0) @@ -352,6 +362,40 @@ export function WorkspaceListItem({ {isBranchWorkspace ? "local" : name || branch} + {shouldShowRepairWarning && ( + + + + + + + +

+ {repairState === "repairing" + ? "Repairing worktree path" + : "Worktree needs attention"} +

+

+ {repairMessage} +

+ {repairCommand && ( +

+ {repairCommand} +

+ )} +
+
+ )} + {isBranchWorkspace && aheadBehind && ( (null); + const utils = electronTrpc.useUtils(); // tRPC mutations const createOrAttachMutation = useCreateOrAttachWithTheme(); @@ -38,8 +40,29 @@ export function useTerminalConnection({ const { data: workspaceCwd } = electronTrpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + const runCreateOrAttach: CreateOrAttachMutate = (input, callbacks) => { + createOrAttachMutation.mutate(input, { + onSuccess: (data) => { + if (data.pathChanged) { + void Promise.all([ + utils.workspaces.get.invalidate({ id: workspaceId }), + utils.workspaces.getAllGrouped.invalidate(), + utils.terminal.getWorkspaceCwd.invalidate(workspaceId), + ]); + } + callbacks?.onSuccess?.(data); + }, + onError: (error) => { + callbacks?.onError?.({ message: error.message }); + }, + onSettled: () => { + callbacks?.onSettled?.(); + }, + }); + }; + // Stable refs - these don't change identity on re-render - const createOrAttachRef = useRef(createOrAttachMutation.mutate); + const createOrAttachRef = useRef(runCreateOrAttach); // Use imperative client calls for write/resize/detach/clear to avoid // mutation-observer re-renders on every keystroke. const writeRef = useRef((input, callbacks) => { @@ -74,7 +97,7 @@ export function useTerminalConnection({ }); // Keep refs up to date - createOrAttachRef.current = createOrAttachMutation.mutate; + createOrAttachRef.current = runCreateOrAttach; return { // Connection error state diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts index b38d86275df..0cb2cf0d4cd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts @@ -20,6 +20,7 @@ export type TerminalStreamEvent = export type CreateOrAttachResult = { wasRecovered: boolean; isNew: boolean; + pathChanged?: boolean; scrollback: string; // Cold restore fields (for reboot recovery) isColdRestore?: boolean;