From d8a4c52d7718c555bc17f7107aa0be78217ba803 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Mar 2026 17:33:51 -0800 Subject: [PATCH 1/6] fix(desktop): auto-repair worktree path when directory is moved/unnested When a worktree directory is moved or unnested, the stored path in the local database becomes stale, causing terminal connections to fail with "Workspace path does not exist" errors. This adds auto-detection of the new path by querying `git worktree list` and matching by branch name, then updating the database so the terminal (and git status) can reconnect. --- .../src/lib/trpc/routers/terminal/terminal.ts | 20 +++++- .../workspaces/procedures/git-status.ts | 12 +++- .../workspaces/utils/repair-worktree-path.ts | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 9d450745f0b..56796ab7572 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { projects, workspaces, worktrees } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; @@ -13,6 +14,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 { tryRepairWorktreePath } from "../workspaces/utils/repair-worktree-path"; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveTerminalThemeType } from "./theme-type"; @@ -90,9 +92,25 @@ export const createTerminalRouter = () => { .from(workspaces) .where(eq(workspaces.id, workspaceId)) .get(); - const workspacePath = workspace + let workspacePath = workspace ? (getWorkspacePath(workspace) ?? undefined) : undefined; + + // If the stored worktree path is stale (directory moved/unnested), + // try to auto-detect the new path via `git worktree list` + if ( + workspace?.type === "worktree" && + workspace.worktreeId && + (!workspacePath || !existsSync(workspacePath)) + ) { + const repairedPath = await tryRepairWorktreePath( + workspace.worktreeId, + ); + if (repairedPath) { + workspacePath = repairedPath; + } + } + if (workspace?.type === "worktree") { assertWorkspaceUsable(workspaceId, workspacePath); } 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..aab2db755cb 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 @@ -18,6 +18,7 @@ import { refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; +import { tryRepairWorktreePath } from "../utils/repair-worktree-path"; export const createGitStatusProcedures = () => { return router({ @@ -61,8 +62,17 @@ export const createGitStatusProcedures = () => { await fetchDefaultBranch(project.mainRepoPath, defaultBranch); + // Repair stale worktree path if directory was moved/unnested + let worktreePath = worktree.path; + if (!existsSync(worktreePath)) { + const repairedPath = await tryRepairWorktreePath(worktree.id); + if (repairedPath) { + worktreePath = repairedPath; + } + } + const { ahead, behind } = await getAheadBehindCount({ - repoPath: worktree.path, + repoPath: worktreePath, defaultBranch, }); 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..963e4845b11 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts @@ -0,0 +1,68 @@ +import { existsSync } 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; + + // 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; + } +} From 0fc3d72aedc24706e20b04aeac7d480ca838db28 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Mar 2026 22:32:55 -0800 Subject: [PATCH 2/6] fix(desktop): reject main repo path when repairing worktree path `git worktree list` includes the main worktree. If the stale worktree's branch happens to be checked out in the main repo, the repair would incorrectly rebind the worktree row to mainRepoPath. Guard against this by comparing the candidate path against the project's main repo path. --- .../trpc/routers/workspaces/utils/repair-worktree-path.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 index 963e4845b11..92e6f4103a6 100644 --- 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 @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { projects, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; @@ -45,6 +46,12 @@ export async function tryRepairWorktreePath( 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. + if (resolve(actualPath) === resolve(project.mainRepoPath)) return null; + // Path has changed - update the database if (actualPath !== worktree.path) { console.log( From be7046a51d6a1004d20d9d481d4711f0c2296caf Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 5 Mar 2026 15:35:26 -0800 Subject: [PATCH 3/6] fix(desktop): use realpathSync for main-repo guard and add regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace resolve() with realpathSync() to canonicalize symlinks (e.g. /var → /private/var on macOS) when comparing candidate path against main repo path. - Add 6 regression tests covering: valid path no-op, successful repair after git worktree move, main repo rejection, missing project/worktree, and branch not found by git. --- .../utils/repair-worktree-path.test.ts | 222 ++++++++++++++++++ .../workspaces/utils/repair-worktree-path.ts | 7 +- 2 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.test.ts 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..6b38f796d52 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.test.ts @@ -0,0 +1,222 @@ +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 . && 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 { 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("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("rejects candidate when it equals the main repo path", async () => { + const mainRepo = createTestRepo("main-reject"); + seedCommit(mainRepo); + + // The main repo's branch (e.g. "main") is checked out in the main worktree. + // Simulate a stale worktree that had branch "main". + const stalePath = join(TEST_DIR, "wt-gone"); + + mockWorktrees.set("wt-3", { + id: "wt-3", + path: stalePath, + branch: "main", + 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(); + }); +}); 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 index 92e6f4103a6..34e5440c0f1 100644 --- 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 @@ -1,5 +1,4 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; +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"; @@ -50,7 +49,9 @@ export async function tryRepairWorktreePath( // `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. - if (resolve(actualPath) === resolve(project.mainRepoPath)) return null; + // 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) { From 9800358cf6d5391dbd5c1be056f6009977b6b163 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 5 Mar 2026 15:57:11 -0800 Subject: [PATCH 4/6] test: derive default branch in main-repo rejection test Use git rev-parse instead of hardcoding "main" so the test exercises the guard on environments where git init defaults to "master". --- .../workspaces/utils/repair-worktree-path.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 index 6b38f796d52..928a17e3b50 100644 --- 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 @@ -175,14 +175,21 @@ describe("tryRepairWorktreePath", () => { const mainRepo = createTestRepo("main-reject"); seedCommit(mainRepo); - // The main repo's branch (e.g. "main") is checked out in the main worktree. - // Simulate a stale worktree that had branch "main". + // 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: "main", + branch: defaultBranch, projectId: "proj-3", }); mockProjects.set("proj-3", { id: "proj-3", mainRepoPath: mainRepo }); From dbdc0795f521853047d6b1529f157dcb522cb522 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 5 Mar 2026 16:16:18 -0800 Subject: [PATCH 5/6] fix(desktop): fail explicitly when worktree path is missing in refreshGitStatus After attempting path repair, check existsSync again before calling getAheadBehindCount. Without this, a stale path silently produces ahead=0/behind=0 which misleads the UI into showing the workspace as up-to-date. --- .../lib/trpc/routers/workspaces/procedures/git-status.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 aab2db755cb..e8c062fde5b 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"; @@ -71,6 +72,14 @@ export const createGitStatusProcedures = () => { } } + 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: worktreePath, defaultBranch, From a48b3bc4c7f3923888b358ea68d6537e6e88f08c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 6 Mar 2026 00:36:37 -0800 Subject: [PATCH 6/6] fix(desktop): repair stale worktree paths in workspace queries --- .../src/lib/trpc/routers/terminal/terminal.ts | 36 ++------ .../workspaces/procedures/git-status.ts | 24 +++--- .../routers/workspaces/procedures/query.ts | 47 +++++++---- .../utils/repair-worktree-path.test.ts | 84 +++++++++++++++++-- .../workspaces/utils/repair-worktree-path.ts | 21 +++++ .../Terminal/hooks/useTerminalConnection.ts | 25 +++++- 6 files changed, 175 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 56796ab7572..007c0d165b1 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -1,5 +1,4 @@ -import { existsSync } from "node:fs"; -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"; @@ -14,7 +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 { tryRepairWorktreePath } from "../workspaces/utils/repair-worktree-path"; +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"; @@ -92,25 +91,13 @@ export const createTerminalRouter = () => { .from(workspaces) .where(eq(workspaces.id, workspaceId)) .get(); - let workspacePath = workspace - ? (getWorkspacePath(workspace) ?? undefined) + const workspacePath = workspace + ? workspace.type === "worktree" && workspace.worktreeId + ? ((await resolveWorktreePathWithRepair(workspace.worktreeId)) ?? + undefined) + : (getWorkspacePath(workspace) ?? undefined) : undefined; - // If the stored worktree path is stale (directory moved/unnested), - // try to auto-detect the new path via `git worktree list` - if ( - workspace?.type === "worktree" && - workspace.worktreeId && - (!workspacePath || !existsSync(workspacePath)) - ) { - const repairedPath = await tryRepairWorktreePath( - workspace.worktreeId, - ); - if (repairedPath) { - workspacePath = repairedPath; - } - } - if (workspace?.type === "worktree") { assertWorkspaceUsable(workspaceId, workspacePath); } @@ -429,7 +416,7 @@ export const createTerminalRouter = () => { getWorkspaceCwd: publicProcedure .input(z.string()) - .query(({ input: workspaceId }) => { + .query(async ({ input: workspaceId }) => { const workspace = localDb .select() .from(workspaces) @@ -443,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 e8c062fde5b..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 @@ -19,7 +19,7 @@ import { refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; -import { tryRepairWorktreePath } from "../utils/repair-worktree-path"; +import { resolveWorktreePathWithRepair } from "../utils/repair-worktree-path"; export const createGitStatusProcedures = () => { return router({ @@ -64,13 +64,8 @@ export const createGitStatusProcedures = () => { await fetchDefaultBranch(project.mainRepoPath, defaultBranch); // Repair stale worktree path if directory was moved/unnested - let worktreePath = worktree.path; - if (!existsSync(worktreePath)) { - const repairedPath = await tryRepairWorktreePath(worktree.id); - if (repairedPath) { - worktreePath = repairedPath; - } - } + const worktreePath = + (await resolveWorktreePathWithRepair(worktree.id)) ?? worktree.path; if (!existsSync(worktreePath)) { throw new TRPCError({ @@ -136,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 @@ -151,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; @@ -164,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 228a3cc0a13..af538cf130b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,13 +1,31 @@ -import { projects, workspaces, worktrees } from "@superset/local-db"; +import { + projects, + type SelectWorkspace, + workspaces, + worktrees, +} from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { eq, isNotNull, isNull } from "drizzle-orm"; 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 { 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 workspace.tabOrder). */ function getWorkspacesInVisualOrder(): string[] { @@ -66,7 +84,7 @@ export const createQueryProcedures = () => { return { ...workspace, type: workspace.type as "worktree" | "branch", - worktreePath: getWorkspacePath(workspace) ?? "", + worktreePath: (await getWorkspacePathForQuery(workspace)) ?? "", project: project ? { id: project.id, @@ -95,18 +113,13 @@ export const createQueryProcedures = () => { .sort((a, b) => a.tabOrder - b.tabOrder); }), - getAllGrouped: publicProcedure.query(() => { + getAllGrouped: publicProcedure.query(async () => { const activeProjects = localDb .select() .from(projects) .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 groupsMap = new Map< string, { @@ -162,16 +175,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; - } - group.workspaces.push({ ...workspace, type: workspace.type as "worktree" | "branch", 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 index 928a17e3b50..b57a352cc7e 100644 --- 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 @@ -33,10 +33,8 @@ function createTestRepo(name: string): string { function seedCommit(repoPath: string): void { writeFileSync(join(repoPath, "README.md"), "# test\n"); - execSync("git add . && git commit -m 'init'", { - cwd: repoPath, - stdio: "ignore", - }); + execSync("git add .", { cwd: repoPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: repoPath, stdio: "ignore" }); } // --------------------------------------------------------------------------- @@ -98,7 +96,9 @@ mock.module("main/lib/local-db", () => ({ })); // Import after mocks are registered -const { tryRepairWorktreePath } = await import("./repair-worktree-path"); +const { resolveWorktreePathWithRepair, tryRepairWorktreePath } = await import( + "./repair-worktree-path" +); // --------------------------------------------------------------------------- // Tests @@ -143,6 +143,31 @@ describe("tryRepairWorktreePath", () => { 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); @@ -171,6 +196,36 @@ describe("tryRepairWorktreePath", () => { 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); @@ -226,4 +281,23 @@ describe("tryRepairWorktreePath", () => { 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 index 34e5440c0f1..359e579c502 100644 --- 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 @@ -74,3 +74,24 @@ export async function tryRepairWorktreePath( 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