diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 9d450745f0b..007c0d165b1 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -1,4 +1,4 @@ -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"; @@ -13,6 +13,7 @@ import { getTerminalHostClient } from "main/lib/terminal-host/client"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { resolveWorktreePathWithRepair } from "../workspaces/utils/repair-worktree-path"; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveTerminalThemeType } from "./theme-type"; @@ -91,8 +92,12 @@ export const createTerminalRouter = () => { .where(eq(workspaces.id, workspaceId)) .get(); const workspacePath = workspace - ? (getWorkspacePath(workspace) ?? undefined) + ? workspace.type === "worktree" && workspace.worktreeId + ? ((await resolveWorktreePathWithRepair(workspace.worktreeId)) ?? + undefined) + : (getWorkspacePath(workspace) ?? undefined) : undefined; + if (workspace?.type === "worktree") { assertWorkspaceUsable(workspaceId, workspacePath); } @@ -411,7 +416,7 @@ export const createTerminalRouter = () => { getWorkspaceCwd: publicProcedure .input(z.string()) - .query(({ input: workspaceId }) => { + .query(async ({ input: workspaceId }) => { const workspace = localDb .select() .from(workspaces) @@ -425,12 +430,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/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index c07ff59097c..7d6bb8c9cdd 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 { TRPCError } from "@trpc/server"; import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; @@ -18,6 +19,7 @@ import { refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; +import { resolveWorktreePathWithRepair } from "../utils/repair-worktree-path"; export const createGitStatusProcedures = () => { return router({ @@ -61,8 +63,20 @@ export const createGitStatusProcedures = () => { await fetchDefaultBranch(project.mainRepoPath, defaultBranch); + // Repair stale worktree path if directory was moved/unnested + const worktreePath = + (await resolveWorktreePathWithRepair(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 +131,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 +151,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 +164,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 { 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 2871d5ffd98..518b086b899 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, @@ -10,10 +11,23 @@ import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getWorkspace } from "../utils/db-helpers"; +import { resolveWorktreePathWithRepair } from "../utils/repair-worktree-path"; import { computeVisualOrder } from "../utils/visual-order"; import { getWorkspacePath } from "../utils/worktree"; -type WorktreePathMap = Map; +async function getWorkspacePathForQuery( + workspace: SelectWorkspace, +): Promise { + if (workspace.type === "branch") { + return getWorkspacePath(workspace); + } + + if (!workspace.worktreeId) { + return null; + } + + return resolveWorktreePathWithRepair(workspace.worktreeId); +} /** Returns workspace IDs in sidebar visual order (by project.tabOrder, then ungrouped workspaces, then sections by tabOrder). */ function getWorkspacesInVisualOrder(): string[] { @@ -63,7 +77,7 @@ export const createQueryProcedures = () => { return { ...workspace, type: workspace.type as "worktree" | "branch", - worktreePath: getWorkspacePath(workspace) ?? "", + worktreePath: (await getWorkspacePathForQuery(workspace)) ?? "", project: project ? { id: project.id, @@ -92,7 +106,7 @@ export const createQueryProcedures = () => { .sort((a, b) => a.tabOrder - b.tabOrder); }), - getAllGrouped: publicProcedure.query(() => { + getAllGrouped: publicProcedure.query(async () => { type WorkspaceItem = { id: string; projectId: string; @@ -125,13 +139,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, { @@ -152,14 +160,14 @@ 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, - name: s.name, - tabOrder: s.tabOrder, - isCollapsed: s.isCollapsed ?? false, - color: s.color ?? null, + .map((section) => ({ + id: section.id, + name: section.name, + tabOrder: section.tabOrder, + isCollapsed: section.isCollapsed ?? false, + color: section.color ?? null, workspaces: [] as WorkspaceItem[], })); @@ -187,16 +195,16 @@ export const createQueryProcedures = () => { .all() .sort((a, b) => a.tabOrder - b.tabOrder); - for (const workspace of allWorkspaces) { + const workspacesWithResolvedPaths = await Promise.all( + allWorkspaces.map(async (workspace) => ({ + workspace, + worktreePath: (await getWorkspacePathForQuery(workspace)) ?? "", + })), + ); + + for (const { workspace, worktreePath } of workspacesWithResolvedPaths) { 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 item: WorkspaceItem = { ...workspace, sectionId: workspace.sectionId ?? null, @@ -208,7 +216,7 @@ export const createQueryProcedures = () => { 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/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..b57a352cc7e --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, mock, 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"; + +// --------------------------------------------------------------------------- +// Test helpers – real git repos on disk +// --------------------------------------------------------------------------- + +const TEST_DIR = join( + realpathSync(tmpdir()), + `superset-test-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; + +// Sentinel objects so the mock `from()` can distinguish tables +const WORKTREES_TABLE = Symbol("worktrees"); +const PROJECTS_TABLE = Symbol("projects"); + +mock.module("@superset/local-db", () => ({ + worktrees: WORKTREES_TABLE, + projects: PROJECTS_TABLE, +})); + +mock.module("drizzle-orm", () => ({ + eq: (_field: unknown, value: string) => value, +})); + +mock.module("main/lib/local-db", () => ({ + localDb: { + select: () => ({ + from: (table: symbol) => ({ + where: (id: string) => ({ + get: () => { + if (table === WORKTREES_TABLE) return mockWorktrees.get(id); + if (table === PROJECTS_TABLE) return mockProjects.get(id); + return undefined; + }, + }), + }), + }), + update: (_table: symbol) => ({ + set: (values: { path?: string }) => ({ + where: (id: string) => ({ + run: () => { + const wt = mockWorktrees.get(id); + if (wt && values.path) wt.path = values.path; + }, + }), + }), + }), + }, +})); + +// Import after mocks are registered +const { resolveWorktreePathWithRepair, tryRepairWorktreePath } = await import( + "./repair-worktree-path" +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("tryRepairWorktreePath", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + mockWorktrees = new Map(); + mockProjects = new Map(); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + 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("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(); + }); +}); 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..359e579c502 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts @@ -0,0 +1,97 @@ +import { existsSync, realpathSync } from "node:fs"; +import { projects, worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { getBranchWorktreePath } from "./git"; + +/** + * Attempts to repair a worktree's stored path when it no longer exists on disk. + * + * When a worktree directory is moved (e.g., via `git worktree move` or manual + * unnesting), the path stored in the local database becomes stale. This function + * queries `git worktree list` from the main repo to find the worktree's current + * path by matching on branch name, then updates the database if a valid new path + * is found. + * + * @returns The repaired path if successful, null otherwise + */ +export async function tryRepairWorktreePath( + worktreeId: string, +): Promise { + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, worktreeId)) + .get(); + + if (!worktree) return null; + + // If path already exists, no repair needed + if (existsSync(worktree.path)) return worktree.path; + + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, worktree.projectId)) + .get(); + + if (!project) return null; + + try { + const actualPath = await getBranchWorktreePath({ + mainRepoPath: project.mainRepoPath, + branch: worktree.branch, + }); + + if (!actualPath || !existsSync(actualPath)) return null; + + // Reject if the candidate resolves to the main repo path. + // `git worktree list` includes the main worktree; if the branch + // happens to be checked out there, we must not rebind this + // worktree row to the main repo. + // Use realpathSync to canonicalize symlinks (e.g. /var → /private/var on macOS). + if (realpathSync(actualPath) === realpathSync(project.mainRepoPath)) + return null; + + // Path has changed - update the database + if (actualPath !== worktree.path) { + console.log( + `[repair-worktree-path] Worktree path changed: "${worktree.path}" → "${actualPath}" (branch: ${worktree.branch})`, + ); + localDb + .update(worktrees) + .set({ path: actualPath }) + .where(eq(worktrees.id, worktreeId)) + .run(); + } + + return actualPath; + } catch (error) { + console.warn( + `[repair-worktree-path] Failed to repair path for worktree ${worktreeId}:`, + error instanceof Error ? error.message : error, + ); + return null; + } +} + +/** + * 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 worktree = localDb + .select({ path: worktrees.path }) + .from(worktrees) + .where(eq(worktrees.id, worktreeId)) + .get(); + + if (!worktree) return null; + if (existsSync(worktree.path)) return worktree.path; + + return tryRepairWorktreePath(worktreeId); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts index 3dc57265ba0..b9d2b9e9a04 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts @@ -3,6 +3,7 @@ import { useCreateOrAttachWithTheme } from "renderer/hooks/useCreateOrAttachWith import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import type { + CreateOrAttachMutate, TerminalClearScrollbackMutate, TerminalDetachMutate, TerminalResizeMutate, @@ -30,6 +31,7 @@ export function useTerminalConnection({ workspaceId, }: UseTerminalConnectionOptions) { const [connectionError, setConnectionError] = useState(null); + const utils = electronTrpc.useUtils(); // tRPC mutations const createOrAttachMutation = useCreateOrAttachWithTheme(); @@ -38,8 +40,27 @@ export function useTerminalConnection({ const { data: workspaceCwd } = electronTrpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + const runCreateOrAttach: CreateOrAttachMutate = (input, callbacks) => { + createOrAttachMutation.mutate(input, { + onSuccess: (data) => { + 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 +95,7 @@ export function useTerminalConnection({ }); // Keep refs up to date - createOrAttachRef.current = createOrAttachMutation.mutate; + createOrAttachRef.current = runCreateOrAttach; return { // Connection error state