From d8a4c52d7718c555bc17f7107aa0be78217ba803 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Mar 2026 17:33:51 -0800 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 From 29950641f17bc3d034affa390cac6c06df0a544b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 6 Mar 2026 22:26:09 -0800 Subject: [PATCH 07/16] Path issue --- .../routers/workspaces/procedures/create.ts | 74 +++++++------ .../routers/workspaces/procedures/delete.ts | 40 ++++--- .../workspaces/procedures/git-status.ts | 39 ++++--- .../utils/repair-worktree-path.test.ts | 103 +++++++++++++++++- .../workspaces/utils/repair-worktree-path.ts | 70 +++++++++++- 5 files changed, 257 insertions(+), 69 deletions(-) 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 ba10e28bcc3..e2c5a7ac163 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -36,6 +36,11 @@ import { sanitizeBranchNameWithMaxLength, worktreeExists, } from "../utils/git"; +import { + findProjectWorktreeByCurrentPath, + listProjectWorktreesWithCurrentPaths, + resolveWorktreePathWithRepair, +} 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 +82,12 @@ function getPrWorkspaceName(prInfo: PullRequestInfo): string { return prInfo.title || `PR #${prInfo.number}`; } +async function getTrackedWorktreePath( + worktree: typeof worktrees.$inferSelect, +): Promise { + return (await resolveWorktreePathWithRepair(worktree.id)) ?? worktree.path; +} + interface PrWorkspaceResult { workspace: typeof workspaces.$inferSelect; initialCommands: string[] | null; @@ -95,13 +106,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 +132,7 @@ function handleExistingWorktree({ return { workspace: existingWorkspace, initialCommands: null, - worktreePath: existingWorktree.path, + worktreePath, projectId: project.id, prNumber: prInfo.number, prTitle: prInfo.title, @@ -147,14 +159,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 +400,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 +421,7 @@ export const createCreateProcedures = () => { branch, }); if (orphanedWorktree) { + const worktreePath = await getTrackedWorktreePath(orphanedWorktree); const workspace = createWorkspaceFromWorktree({ projectId: input.projectId, worktreeId: orphanedWorktree.id, @@ -433,13 +449,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, @@ -679,10 +695,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"); } @@ -708,7 +722,7 @@ export const createCreateProcedures = () => { const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, + worktreePath, projectId: project.id, }); @@ -721,7 +735,7 @@ export const createCreateProcedures = () => { return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: worktree.path, + worktreePath, projectId: project.id, }; }), @@ -748,16 +762,10 @@ export const createCreateProcedures = () => { 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(); + const existingWorktree = await findProjectWorktreeByCurrentPath( + input.projectId, + input.worktreePath, + ); if (existingWorktree) { // Failed init can leave gitStatus null, which shows "Setup incomplete" UI @@ -990,13 +998,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) @@ -1010,6 +1017,7 @@ export const createCreateProcedures = () => { if (existingWorkspace) continue; + if (!trackedWorktree.existsOnDisk) continue; const exists = await worktreeExists(project.mainRepoPath, wt.path); if (!exists) continue; @@ -1034,7 +1042,11 @@ export const createCreateProcedures = () => { const allExternalWorktrees = await listExternalWorktrees( project.mainRepoPath, ); - const trackedPaths = new Set(projectWorktrees.map((wt) => wt.path)); + const trackedPaths = new Set( + projectWorktrees + .filter((trackedWorktree) => trackedWorktree.existsOnDisk) + .map((trackedWorktree) => trackedWorktree.worktree.path), + ); const externalWorktrees = allExternalWorktrees.filter((wt) => { if (wt.path === project.mainRepoPath) return 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..fa22f0d2dfc 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,15 @@ import { hasUnpushedCommits, worktreeExists, } from "../utils/git"; +import { resolveWorktreePathWithRepair } from "../utils/repair-worktree-path"; import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown"; +async function getTrackedWorktreePath( + worktree: SelectWorktree, +): Promise { + return (await resolveWorktreePathWithRepair(worktree.id)) ?? worktree.path; +} + export const createDeleteProcedures = () => { return router({ canDelete: publicProcedure @@ -93,9 +100,10 @@ export const createDeleteProcedures = () => { if (worktree && project) { try { + const worktreePath = await getTrackedWorktreePath(worktree); const exists = await worktreeExists( project.mainRepoPath, - worktree.path, + worktreePath, ); if (!exists) { @@ -112,8 +120,8 @@ export const createDeleteProcedures = () => { } const [hasChanges, unpushedCommits] = await Promise.all([ - hasUncommittedChanges(worktree.path), - hasUnpushedCommits(worktree.path), + hasUncommittedChanges(worktreePath), + hasUnpushedCommits(worktreePath), ]); return { @@ -194,6 +202,7 @@ export const createDeleteProcedures = () => { const project = getProject(workspace.projectId); let worktree: SelectWorktree | undefined; + let worktreePath: string | undefined; const terminalPromise = getWorkspaceRuntimeRegistry() .getForWorkspaceId(input.id) @@ -204,17 +213,20 @@ export const createDeleteProcedures = () => { | undefined; if (workspace.type === "worktree" && workspace.worktreeId) { worktree = getWorktree(workspace.worktreeId); + worktreePath = worktree + ? await getTrackedWorktreePath(worktree) + : undefined; - if (worktree && project && existsSync(worktree.path)) { + if (worktreePath && project && existsSync(worktreePath)) { teardownPromise = runTeardown({ mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, + worktreePath, workspaceName: workspace.name, projectId: project.id, }); } else { console.warn( - `[workspace/delete] Skipping teardown: worktree=${!!worktree}, project=${!!project}, pathExists=${worktree ? existsSync(worktree.path) : "N/A"}`, + `[workspace/delete] Skipping teardown: worktree=${!!worktree}, project=${!!project}, pathExists=${worktreePath ? existsSync(worktreePath) : "N/A"}`, ); } } else { @@ -254,7 +266,7 @@ export const createDeleteProcedures = () => { try { const removeResult = await removeWorktreeFromDisk({ mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, + worktreePath: worktreePath ?? worktree.path, }); if (!removeResult.success) { clearWorkspaceDeletingStatus(input.id); @@ -372,9 +384,10 @@ export const createDeleteProcedures = () => { } try { + const worktreePath = await getTrackedWorktreePath(worktree); const exists = await worktreeExists( project.mainRepoPath, - worktree.path, + worktreePath, ); if (!exists) { @@ -390,8 +403,8 @@ export const createDeleteProcedures = () => { } const [hasChanges, unpushedCommits] = await Promise.all([ - hasUncommittedChanges(worktree.path), - hasUnpushedCommits(worktree.path), + hasUncommittedChanges(worktreePath), + hasUnpushedCommits(worktreePath), ]); return { @@ -436,15 +449,16 @@ export const createDeleteProcedures = () => { await workspaceInitManager.acquireProjectLock(project.id); try { + const worktreePath = await getTrackedWorktreePath(worktree); const exists = await worktreeExists( project.mainRepoPath, - worktree.path, + worktreePath, ); if (exists) { const teardownResult = await runTeardown({ mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, + worktreePath, workspaceName: worktree.branch, projectId: project.id, }); @@ -467,7 +481,7 @@ export const createDeleteProcedures = () => { if (exists) { const removeResult = await removeWorktreeFromDisk({ mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, + worktreePath, }); if (!removeResult.success) { return removeResult; 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 7d6bb8c9cdd..ec510d132c0 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,10 @@ import { refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; -import { resolveWorktreePathWithRepair } from "../utils/repair-worktree-path"; +import { + listProjectWorktreesWithCurrentPaths, + resolveWorktreePathWithRepair, +} from "../utils/repair-worktree-path"; export const createGitStatusProcedures = () => { return router({ @@ -180,28 +183,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, }; }); @@ -217,12 +218,14 @@ export const createGitStatusProcedures = () => { 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)); + const trackedWorktrees = await listProjectWorktreesWithCurrentPaths( + input.projectId, + ); + const trackedPaths = new Set( + trackedWorktrees + .filter((trackedWorktree) => trackedWorktree.existsOnDisk) + .map((trackedWorktree) => trackedWorktree.worktree.path), + ); return allWorktrees .filter((wt) => { 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 b57a352cc7e..8926f271dac 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 @@ -73,13 +73,35 @@ mock.module("main/lib/local-db", () => ({ localDb: { select: () => ({ from: (table: symbol) => ({ - where: (id: string) => ({ + where: (value: string) => ({ get: () => { - if (table === WORKTREES_TABLE) return mockWorktrees.get(id); - if (table === PROJECTS_TABLE) return mockProjects.get(id); + 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: symbol) => ({ @@ -96,9 +118,12 @@ mock.module("main/lib/local-db", () => ({ })); // Import after mocks are registered -const { resolveWorktreePathWithRepair, tryRepairWorktreePath } = await import( - "./repair-worktree-path" -); +const { + findProjectWorktreeByCurrentPath, + listProjectWorktreesWithCurrentPaths, + resolveWorktreePathWithRepair, + tryRepairWorktreePath, +} = await import("./repair-worktree-path"); // --------------------------------------------------------------------------- // Tests @@ -226,6 +251,72 @@ describe("tryRepairWorktreePath", () => { expect(mockWorktrees.get("wt-resolve-2")?.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); 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 359e579c502..3028e42d7f4 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,5 @@ import { existsSync, realpathSync } from "node:fs"; -import { projects, worktrees } from "@superset/local-db"; +import { projects, type SelectWorktree, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { getBranchWorktreePath } from "./git"; @@ -95,3 +95,71 @@ export async function resolveWorktreePathWithRepair( return tryRepairWorktreePath(worktreeId); } + +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 = localDb + .select() + .from(worktrees) + .where(eq(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; +} From 4bbd965b15ad23a1661076cfcc8cb6322751057c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Mar 2026 14:31:17 -0700 Subject: [PATCH 08/16] fix(desktop): auto-repair moved tracked worktrees --- .../src/lib/trpc/routers/terminal/terminal.ts | 7 +- .../routers/workspaces/procedures/create.ts | 4 +- .../routers/workspaces/procedures/delete.ts | 4 +- .../workspaces/procedures/git-status.ts | 3 +- .../lib/trpc/routers/workspaces/utils/git.ts | 16 + .../utils/repair-worktree-path.test.ts | 81 +++ .../workspaces/utils/repair-worktree-path.ts | 468 ++++++++++++++++-- 7 files changed, 531 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 007c0d165b1..4bb24fa52ae 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -13,7 +13,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 { resolveWorktreePathWithRepair } from "../workspaces/utils/repair-worktree-path"; +import { + resolveWorktreePathOrThrow, + resolveWorktreePathWithRepair, +} from "../workspaces/utils/repair-worktree-path"; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveTerminalThemeType } from "./theme-type"; @@ -93,7 +96,7 @@ export const createTerminalRouter = () => { .get(); const workspacePath = workspace ? workspace.type === "worktree" && workspace.worktreeId - ? ((await resolveWorktreePathWithRepair(workspace.worktreeId)) ?? + ? ((await resolveWorktreePathOrThrow(workspace.worktreeId)) ?? undefined) : (getWorkspacePath(workspace) ?? undefined) : undefined; 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 e2c5a7ac163..00747a8eebc 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -39,7 +39,7 @@ import { import { findProjectWorktreeByCurrentPath, listProjectWorktreesWithCurrentPaths, - resolveWorktreePathWithRepair, + resolveWorktreePathOrThrow, } from "../utils/repair-worktree-path"; import { resolveWorktreePath } from "../utils/resolve-worktree-path"; import { copySupersetConfigToWorktree, loadSetupConfig } from "../utils/setup"; @@ -85,7 +85,7 @@ function getPrWorkspaceName(prInfo: PullRequestInfo): string { async function getTrackedWorktreePath( worktree: typeof worktrees.$inferSelect, ): Promise { - return (await resolveWorktreePathWithRepair(worktree.id)) ?? worktree.path; + return (await resolveWorktreePathOrThrow(worktree.id)) ?? worktree.path; } interface PrWorkspaceResult { 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 fa22f0d2dfc..6ff09871855 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -22,13 +22,13 @@ import { hasUnpushedCommits, worktreeExists, } from "../utils/git"; -import { resolveWorktreePathWithRepair } from "../utils/repair-worktree-path"; +import { resolveWorktreePathOrThrow } from "../utils/repair-worktree-path"; import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown"; async function getTrackedWorktreePath( worktree: SelectWorktree, ): Promise { - return (await resolveWorktreePathWithRepair(worktree.id)) ?? worktree.path; + return (await resolveWorktreePathOrThrow(worktree.id)) ?? worktree.path; } export const createDeleteProcedures = () => { 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 ec510d132c0..4cc751d81a5 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 @@ -21,6 +21,7 @@ import { import { fetchGitHubPRStatus } from "../utils/github"; import { listProjectWorktreesWithCurrentPaths, + resolveWorktreePathOrThrow, resolveWorktreePathWithRepair, } from "../utils/repair-worktree-path"; @@ -68,7 +69,7 @@ export const createGitStatusProcedures = () => { // Repair stale worktree path if directory was moved/unnested const worktreePath = - (await resolveWorktreePathWithRepair(worktree.id)) ?? worktree.path; + (await resolveWorktreePathOrThrow(worktree.id)) ?? worktree.path; if (!existsSync(worktreePath)) { throw new TRPCError({ 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 ca1d769f614..1d64afb247e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -845,6 +845,22 @@ export async function getBranchWorktreePath({ } } +export async function repairWorktreeRegistration({ + mainRepoPath, + worktreePath, +}: { + mainRepoPath: string; + worktreePath: string; +}): Promise { + try { + const git = simpleGit(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 = simpleGit(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 index 8926f271dac..f32ec3e61d4 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 @@ -120,7 +120,10 @@ mock.module("main/lib/local-db", () => ({ // Import after mocks are registered const { findProjectWorktreeByCurrentPath, + getTrackedWorktreeRepairCommand, listProjectWorktreesWithCurrentPaths, + resolveTrackedWorktreePath, + resolveWorktreePathOrThrow, resolveWorktreePathWithRepair, tryRepairWorktreePath, } = await import("./repair-worktree-path"); @@ -140,6 +143,13 @@ describe("tryRepairWorktreePath", () => { if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true, force: true }); } + const externalDir = join( + tmpdir(), + `superset-external-repair-${process.pid}`, + ); + if (existsSync(externalDir)) { + rmSync(externalDir, { recursive: true, force: true }); + } }); test("returns null when worktree record is missing", async () => { @@ -391,4 +401,75 @@ describe("tryRepairWorktreePath", () => { 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( + tmpdir(), + `superset-external-repair-${process.pid}`, + "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), + ); + }); }); 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 3028e42d7f4..28ba810c804 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,33 +1,348 @@ -import { existsSync, realpathSync } from "node:fs"; +import { + 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"; +import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import { getBranchWorktreePath } from "./git"; +import { getBranchWorktreePath, repairWorktreeRegistration } 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( +export type ResolveTrackedWorktreePathResult = + | { + status: "resolved"; + path: string; + } + | { + status: "git_repair_required"; + branch: string; + mainRepoPath: string; + registeredPath: string; + storedPath: string; + } + | { + status: "missing"; + }; + +function buildMissingResolutionResult(): ResolveTrackedWorktreePathResult { + return { status: "missing" }; +} + +const MAX_SEARCH_DEPTH = 2; +const MAX_SCAN_DIRS = 1500; +const SKIPPED_SCAN_DIRS = new Set([ + ".git", + "node_modules", + ".next", + "dist", + "build", + "coverage", + "target", +]); + +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( + mainRepoPath: string, + storedPath: string, +): string[] { + const roots = [ + dirname(storedPath), + dirname(dirname(storedPath)), + dirname(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: { + mainRepoPath: string; + branch: string; + storedPath: string; +}): { + metadataDir: string; + registeredPath: string; +} | null { + const metadataRoot = join(input.mainRepoPath, ".git", "worktrees"); + if (!isExistingDirectory(metadataRoot)) { + return null; + } + + const expectedStoredPath = safeResolvePath(input.storedPath); + + for (const entry of readdirSync(metadataRoot, { withFileTypes: true })) { + 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.branch}` || + safeResolvePath(registeredPath) === expectedStoredPath + ) { + return { metadataDir, registeredPath }; + } + } catch {} + } + + return null; +} + +function findMovedTrackedWorktreeCandidate(input: { + mainRepoPath: string; + storedPath: string; + metadataDir: string; +}): string | null { + const expectedMetadataDir = safeRealpath(input.metadataDir); + const mainRepoRealPath = safeRealpath(input.mainRepoPath); + const searchRoots = getTrackedWorktreeSearchRoots( + input.mainRepoPath, + input.storedPath, + ); + 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: ReturnType; + try { + entries = readdirSync(current.path, { withFileTypes: true }); + } 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: { + mainRepoPath: string; + storedPath: string; + branch: string; +}): Promise { + const metadata = findTrackedWorktreeMetadata(input); + if (!metadata) { + return null; + } + + const candidatePath = findMovedTrackedWorktreeCandidate({ + mainRepoPath: input.mainRepoPath, + storedPath: input.storedPath, + metadataDir: metadata.metadataDir, + }); + + if (!candidatePath) { + return null; + } + + console.log( + `[repair-worktree-path] Found manually moved worktree for branch ${input.branch} at "${candidatePath}", repairing Git registration`, + ); + await repairWorktreeRegistration({ + mainRepoPath: input.mainRepoPath, + worktreePath: candidatePath, + }); + + const repairedPath = await getBranchWorktreePath({ + mainRepoPath: input.mainRepoPath, + branch: input.branch, + }); + + if (repairedPath && existsSync(repairedPath)) { + return repairedPath; + } + + return existsSync(candidatePath) ? candidatePath : null; +} + +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 isMainRepoPath(candidatePath: string, mainRepoPath: string): boolean { + return safeRealpath(candidatePath) === safeRealpath(mainRepoPath); +} + +function persistResolvedTrackedWorktreePath(input: { + worktreeId: string; + worktree: Pick; + resolvedPath: string; +}): ResolveTrackedWorktreePathResult { + if (input.resolvedPath !== input.worktree.path) { + console.log( + `[repair-worktree-path] Worktree path changed: "${input.worktree.path}" → "${input.resolvedPath}" (branch: ${input.worktree.branch})`, + ); + localDb + .update(worktrees) + .set({ path: input.resolvedPath }) + .where(eq(worktrees.id, input.worktreeId)) + .run(); + } + + return { + status: "resolved", + path: input.resolvedPath, + }; +} + +export async function resolveTrackedWorktreePath( worktreeId: string, -): Promise { +): Promise { const worktree = localDb .select() .from(worktrees) .where(eq(worktrees.id, worktreeId)) .get(); - if (!worktree) return null; + if (!worktree) return buildMissingResolutionResult(); - // If path already exists, no repair needed - if (existsSync(worktree.path)) return worktree.path; + if (existsSync(worktree.path)) { + return { + status: "resolved", + path: worktree.path, + }; + } const project = localDb .select() @@ -35,7 +350,7 @@ export async function tryRepairWorktreePath( .where(eq(projects.id, worktree.projectId)) .get(); - if (!project) return null; + if (!project) return buildMissingResolutionResult(); try { const actualPath = await getBranchWorktreePath({ @@ -43,36 +358,107 @@ export async function tryRepairWorktreePath( branch: worktree.branch, }); - if (!actualPath || !existsSync(actualPath)) return null; + if (!actualPath) { + return buildMissingResolutionResult(); + } + + if (!existsSync(actualPath)) { + const repairedPath = await tryAutoRepairTrackedWorktree({ + mainRepoPath: project.mainRepoPath, + storedPath: worktree.path, + branch: worktree.branch, + }); + + if (!repairedPath) { + return { + status: "git_repair_required", + branch: worktree.branch, + mainRepoPath: project.mainRepoPath, + registeredPath: actualPath, + storedPath: worktree.path, + }; + } + + if (isMainRepoPath(repairedPath, project.mainRepoPath)) { + return buildMissingResolutionResult(); + } + + return persistResolvedTrackedWorktreePath({ + worktreeId, + worktree, + resolvedPath: repairedPath, + }); + } // 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(); + if (isMainRepoPath(actualPath, project.mainRepoPath)) { + return buildMissingResolutionResult(); } - return actualPath; + return persistResolvedTrackedWorktreePath({ + worktreeId, + worktree, + resolvedPath: actualPath, + }); } catch (error) { console.warn( `[repair-worktree-path] Failed to repair path for worktree ${worktreeId}:`, error instanceof Error ? error.message : error, ); - return null; + return buildMissingResolutionResult(); + } +} + +export async function resolveWorktreePathOrThrow( + worktreeId: string, +): Promise { + const resolution = await resolveTrackedWorktreePath(worktreeId); + + if (resolution.status === "resolved") { + return resolution.path; } + + if (resolution.status === "git_repair_required") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: getTrackedWorktreeRepairMessage({ + branch: resolution.branch, + mainRepoPath: resolution.mainRepoPath, + }), + cause: { + reason: "git_repair_required", + branch: resolution.branch, + mainRepoPath: resolution.mainRepoPath, + registeredPath: resolution.registeredPath, + storedPath: resolution.storedPath, + command: getTrackedWorktreeRepairCommand(resolution.mainRepoPath), + }, + }); + } + + return null; +} + +/** + * 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 resolution = await resolveTrackedWorktreePath(worktreeId); + return resolution.status === "resolved" ? resolution.path : null; } /** @@ -84,16 +470,8 @@ export async function tryRepairWorktreePath( 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); + const resolution = await resolveTrackedWorktreePath(worktreeId); + return resolution.status === "resolved" ? resolution.path : null; } export async function resolveTrackedWorktree( From 5075753e90e446002489810ae7ba56bcef707a82 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Mar 2026 14:36:02 -0700 Subject: [PATCH 09/16] refactor(desktop): clarify tracked worktree repair flow --- .../utils/repair-worktree-path.test.ts | 21 +- .../workspaces/utils/repair-worktree-path.ts | 294 ++++++++++-------- 2 files changed, 177 insertions(+), 138 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 f32ec3e61d4..7e96bd48c5e 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 @@ -18,6 +18,10 @@ 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); @@ -132,7 +136,7 @@ const { // Tests // --------------------------------------------------------------------------- -describe("tryRepairWorktreePath", () => { +describe("repair-worktree-path", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); mockWorktrees = new Map(); @@ -143,12 +147,8 @@ describe("tryRepairWorktreePath", () => { if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true, force: true }); } - const externalDir = join( - tmpdir(), - `superset-external-repair-${process.pid}`, - ); - if (existsSync(externalDir)) { - rmSync(externalDir, { recursive: true, force: true }); + if (existsSync(EXTERNAL_TEST_DIR)) { + rmSync(EXTERNAL_TEST_DIR, { recursive: true, force: true }); } }); @@ -443,12 +443,7 @@ describe("tryRepairWorktreePath", () => { seedCommit(mainRepo); const oldPath = join(TEST_DIR, "wt-manual-throw-old"); - const externalDir = join( - tmpdir(), - `superset-external-repair-${process.pid}`, - "level-1", - "level-2", - ); + const externalDir = join(EXTERNAL_TEST_DIR, "level-1", "level-2"); mkdirSync(externalDir, { recursive: true }); const newPath = join(externalDir, "wt-manual-throw-new"); execSync( 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 28ba810c804..99b9f96d4ca 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 @@ -28,10 +28,35 @@ export type ResolveTrackedWorktreePathResult = status: "missing"; }; +interface TrackedWorktreeContext { + mainRepoPath: string; + worktree: SelectWorktree; +} + +function buildResolvedResult(path: string): ResolveTrackedWorktreePathResult { + return { + status: "resolved", + path, + }; +} + function buildMissingResolutionResult(): ResolveTrackedWorktreePathResult { return { status: "missing" }; } +function buildGitRepairRequiredResolution( + context: TrackedWorktreeContext, + registeredPath: string, +): ResolveTrackedWorktreePathResult { + return { + status: "git_repair_required", + branch: context.worktree.branch, + mainRepoPath: context.mainRepoPath, + registeredPath, + storedPath: context.worktree.path, + }; +} + const MAX_SEARCH_DEPTH = 2; const MAX_SCAN_DIRS = 1500; const SKIPPED_SCAN_DIRS = new Set([ @@ -94,13 +119,12 @@ function parseGitdirReference(worktreePath: string): string | null { } function getTrackedWorktreeSearchRoots( - mainRepoPath: string, - storedPath: string, + context: TrackedWorktreeContext, ): string[] { const roots = [ - dirname(storedPath), - dirname(dirname(storedPath)), - dirname(mainRepoPath), + dirname(context.worktree.path), + dirname(dirname(context.worktree.path)), + dirname(context.mainRepoPath), ]; const seen = new Set(); @@ -124,19 +148,17 @@ function getTrackedWorktreeSearchRoots( } function findTrackedWorktreeMetadata(input: { - mainRepoPath: string; - branch: string; - storedPath: string; + context: TrackedWorktreeContext; }): { metadataDir: string; registeredPath: string; } | null { - const metadataRoot = join(input.mainRepoPath, ".git", "worktrees"); + const metadataRoot = join(input.context.mainRepoPath, ".git", "worktrees"); if (!isExistingDirectory(metadataRoot)) { return null; } - const expectedStoredPath = safeResolvePath(input.storedPath); + const expectedStoredPath = safeResolvePath(input.context.worktree.path); for (const entry of readdirSync(metadataRoot, { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -160,7 +182,7 @@ function findTrackedWorktreeMetadata(input: { const registeredPath = dirname(registeredGitdir); if ( - head === `ref: refs/heads/${input.branch}` || + head === `ref: refs/heads/${input.context.worktree.branch}` || safeResolvePath(registeredPath) === expectedStoredPath ) { return { metadataDir, registeredPath }; @@ -172,16 +194,12 @@ function findTrackedWorktreeMetadata(input: { } function findMovedTrackedWorktreeCandidate(input: { - mainRepoPath: string; - storedPath: string; + context: TrackedWorktreeContext; metadataDir: string; }): string | null { const expectedMetadataDir = safeRealpath(input.metadataDir); - const mainRepoRealPath = safeRealpath(input.mainRepoPath); - const searchRoots = getTrackedWorktreeSearchRoots( - input.mainRepoPath, - input.storedPath, - ); + 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; @@ -250,18 +268,17 @@ function findMovedTrackedWorktreeCandidate(input: { } async function tryAutoRepairTrackedWorktree(input: { - mainRepoPath: string; - storedPath: string; - branch: string; + context: TrackedWorktreeContext; }): Promise { - const metadata = findTrackedWorktreeMetadata(input); + const metadata = findTrackedWorktreeMetadata({ + context: input.context, + }); if (!metadata) { return null; } const candidatePath = findMovedTrackedWorktreeCandidate({ - mainRepoPath: input.mainRepoPath, - storedPath: input.storedPath, + context: input.context, metadataDir: metadata.metadataDir, }); @@ -270,17 +287,33 @@ async function tryAutoRepairTrackedWorktree(input: { } console.log( - `[repair-worktree-path] Found manually moved worktree for branch ${input.branch} at "${candidatePath}", repairing Git registration`, + `[repair-worktree-path] Found manually moved worktree for branch ${input.context.worktree.branch} at "${candidatePath}", repairing Git registration`, ); - await repairWorktreeRegistration({ - mainRepoPath: input.mainRepoPath, - worktreePath: candidatePath, - }); + try { + await 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; + } - const repairedPath = await getBranchWorktreePath({ - mainRepoPath: input.mainRepoPath, - branch: input.branch, - }); + let repairedPath: string | null = null; + try { + repairedPath = await 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; @@ -300,48 +333,17 @@ export function getTrackedWorktreeRepairMessage(input: { 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 isMainRepoPath(candidatePath: string, mainRepoPath: string): boolean { - return safeRealpath(candidatePath) === safeRealpath(mainRepoPath); -} - -function persistResolvedTrackedWorktreePath(input: { - worktreeId: string; - worktree: Pick; - resolvedPath: string; -}): ResolveTrackedWorktreePathResult { - if (input.resolvedPath !== input.worktree.path) { - console.log( - `[repair-worktree-path] Worktree path changed: "${input.worktree.path}" → "${input.resolvedPath}" (branch: ${input.worktree.branch})`, - ); - localDb - .update(worktrees) - .set({ path: input.resolvedPath }) - .where(eq(worktrees.id, input.worktreeId)) - .run(); - } - - return { - status: "resolved", - path: input.resolvedPath, - }; -} - -export async function resolveTrackedWorktreePath( +function getTrackedWorktreeContext( worktreeId: string, -): Promise { +): TrackedWorktreeContext | null { const worktree = localDb .select() .from(worktrees) .where(eq(worktrees.id, worktreeId)) .get(); - if (!worktree) return buildMissingResolutionResult(); - - if (existsSync(worktree.path)) { - return { - status: "resolved", - path: worktree.path, - }; + if (!worktree) { + return null; } const project = localDb @@ -350,67 +352,111 @@ export async function resolveTrackedWorktreePath( .where(eq(projects.id, worktree.projectId)) .get(); - if (!project) return buildMissingResolutionResult(); - - try { - const actualPath = await getBranchWorktreePath({ - mainRepoPath: project.mainRepoPath, - branch: worktree.branch, - }); - - if (!actualPath) { - return buildMissingResolutionResult(); - } + if (!project) { + return null; + } - if (!existsSync(actualPath)) { - const repairedPath = await tryAutoRepairTrackedWorktree({ - mainRepoPath: project.mainRepoPath, - storedPath: worktree.path, - branch: worktree.branch, - }); + return { + mainRepoPath: project.mainRepoPath, + worktree, + }; +} - if (!repairedPath) { - return { - status: "git_repair_required", - branch: worktree.branch, - mainRepoPath: project.mainRepoPath, - registeredPath: actualPath, - storedPath: worktree.path, - }; - } +function isMainRepoPath( + context: TrackedWorktreeContext, + candidatePath: string, +): boolean { + return safeRealpath(candidatePath) === safeRealpath(context.mainRepoPath); +} - if (isMainRepoPath(repairedPath, project.mainRepoPath)) { - return buildMissingResolutionResult(); - } +function persistResolvedTrackedWorktreePath(input: { + context: TrackedWorktreeContext; + resolvedPath: string; +}): ResolveTrackedWorktreePathResult { + if (isMainRepoPath(input.context, input.resolvedPath)) { + return buildMissingResolutionResult(); + } - return persistResolvedTrackedWorktreePath({ - worktreeId, - worktree, - resolvedPath: repairedPath, - }); - } + if (input.resolvedPath !== input.context.worktree.path) { + console.log( + `[repair-worktree-path] Worktree path changed: "${input.context.worktree.path}" → "${input.resolvedPath}" (branch: ${input.context.worktree.branch})`, + ); + localDb + .update(worktrees) + .set({ path: input.resolvedPath }) + .where(eq(worktrees.id, input.context.worktree.id)) + .run(); + } - // 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 (isMainRepoPath(actualPath, project.mainRepoPath)) { - return buildMissingResolutionResult(); - } + return buildResolvedResult(input.resolvedPath); +} - return persistResolvedTrackedWorktreePath({ - worktreeId, - worktree, - resolvedPath: actualPath, +async function getRegisteredTrackedWorktreePath( + context: TrackedWorktreeContext, +): Promise { + try { + return await getBranchWorktreePath({ + mainRepoPath: context.mainRepoPath, + branch: context.worktree.branch, }); } catch (error) { console.warn( - `[repair-worktree-path] Failed to repair path for worktree ${worktreeId}:`, + `[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) { return buildMissingResolutionResult(); } + + if (existsSync(registeredPath)) { + return persistResolvedTrackedWorktreePath({ + context, + resolvedPath: registeredPath, + }); + } + + const repairedPath = await tryAutoRepairTrackedWorktree({ + context, + }); + + if (!repairedPath) { + return buildGitRepairRequiredResolution(context, registeredPath); + } + + return persistResolvedTrackedWorktreePath({ + context, + resolvedPath: repairedPath, + }); +} + +function getResolvedTrackedWorktreePath( + resolution: ResolveTrackedWorktreePathResult, +): string | null { + return resolution.status === "resolved" ? resolution.path : null; +} + +export async function resolveTrackedWorktreePath( + worktreeId: string, +): Promise { + const context = getTrackedWorktreeContext(worktreeId); + if (!context) { + return buildMissingResolutionResult(); + } + + if (existsSync(context.worktree.path)) { + return buildResolvedResult(context.worktree.path); + } + + return resolveTrackedWorktreePathFromGitState(context); } export async function resolveWorktreePathOrThrow( @@ -444,21 +490,19 @@ export async function resolveWorktreePathOrThrow( } /** - * Attempts to repair a worktree's stored path when it no longer exists on disk. + * Attempts to resolve a tracked worktree path, repairing stale Git registrations + * when possible. * - * 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. + * 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 { - const resolution = await resolveTrackedWorktreePath(worktreeId); - return resolution.status === "resolved" ? resolution.path : null; + return resolveWorktreePathWithRepair(worktreeId); } /** @@ -471,7 +515,7 @@ export async function resolveWorktreePathWithRepair( worktreeId: string, ): Promise { const resolution = await resolveTrackedWorktreePath(worktreeId); - return resolution.status === "resolved" ? resolution.path : null; + return getResolvedTrackedWorktreePath(resolution); } export async function resolveTrackedWorktree( From 5e8ed7b687e164ec7825100ab24d79b3368dd485 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Mar 2026 18:50:43 -0700 Subject: [PATCH 10/16] fix(desktop): stabilize worktree repair tests and typing --- .../utils/repair-worktree-path.test.ts | 2 +- .../workspaces/utils/repair-worktree-path.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 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 7e96bd48c5e..a3900b17b42 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 @@ -64,7 +64,7 @@ let mockProjects: Map; const WORKTREES_TABLE = Symbol("worktrees"); const PROJECTS_TABLE = Symbol("projects"); -mock.module("@superset/local-db", () => ({ +mock.module("@superset/local-db/schema", () => ({ worktrees: WORKTREES_TABLE, projects: PROJECTS_TABLE, })); 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 99b9f96d4ca..763a81b913f 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 { + type Dirent, existsSync, readdirSync, readFileSync, @@ -6,7 +7,11 @@ import { statSync, } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; -import { projects, type SelectWorktree, worktrees } from "@superset/local-db"; +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"; @@ -69,6 +74,10 @@ const SKIPPED_SCAN_DIRS = new Set([ "target", ]); +function readDirectoryEntries(path: string): Dirent[] { + return readdirSync(path, { withFileTypes: true }); +} + function safeResolvePath(path: string): string { return resolve(path); } @@ -160,7 +169,7 @@ function findTrackedWorktreeMetadata(input: { const expectedStoredPath = safeResolvePath(input.context.worktree.path); - for (const entry of readdirSync(metadataRoot, { withFileTypes: true })) { + for (const entry of readDirectoryEntries(metadataRoot)) { if (!entry.isDirectory()) { continue; } @@ -236,9 +245,9 @@ function findMovedTrackedWorktreeCandidate(input: { continue; } - let entries: ReturnType; + let entries: Dirent[]; try { - entries = readdirSync(current.path, { withFileTypes: true }); + entries = readDirectoryEntries(current.path); } catch { continue; } From 6012ecfd11a3f210444ede5c919d1324406ea5a1 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Mar 2026 19:17:50 -0700 Subject: [PATCH 11/16] fix(desktop): keep stale worktrees deletable --- .../workspaces/procedures/delete.test.ts | 314 +++++++++++ .../routers/workspaces/procedures/delete.ts | 492 ++++++++++++------ .../routers/workspaces/utils/db-helpers.ts | 2 +- .../utils/repair-worktree-path.test.ts | 136 ++--- .../workspaces/utils/repair-worktree-path.ts | 76 ++- 5 files changed, 780 insertions(+), 240 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.test.ts 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..9e7aab42ad6 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.test.ts @@ -0,0 +1,314 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { TRPCError } from "@trpc/server"; + +import { + __testOnlyDeleteProcedureDeps, + createDeleteProcedures, +} from "./delete"; + +interface MockWorkspace { + id: string; + projectId: string; + worktreeId: string | null; + type: "worktree" | "branch"; + name: string; + branch: string; + deletingAt: number | null; +} + +interface MockWorktree { + id: string; + projectId: string; + path: string; + branch: string; +} + +interface MockProject { + id: string; + mainRepoPath: string; +} + +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 () => ({ 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<(worktreeId: string) => Promise>(); +const runTeardownMock = mock(async () => ({ success: true as const })); +const removeWorktreeFromDiskMock = mock(async () => ({ + success: true as const, +})); + +function createCaller() { + return createDeleteProcedures().createCaller({}); +} + +function seedTrackedWorkspace() { + projects.set("proj-1", { + id: "proj-1", + mainRepoPath: "/repo/main", + }); + worktrees.set("wt-1", { + id: "wt-1", + projectId: "proj-1", + path: "/repo/wt-old", + branch: "feat-move", + }); + workspaces.set("ws-1", { + id: "ws-1", + projectId: "proj-1", + worktreeId: "wt-1", + type: "worktree", + name: "feat-move", + branch: "feat-move", + deletingAt: null, + }); +} + +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({ 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); + __testOnlyDeleteProcedureDeps.getWorkspaceRuntimeRegistry = () => ({ + getForWorkspaceId: () => ({ + terminal: { + killByWorkspaceId: (...args) => killByWorkspaceIdMock(...args), + getSessionCountByWorkspaceId: (...args) => + getSessionCountByWorkspaceIdMock(...args), + }, + }), + }); + __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 6ff09871855..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,13 +22,97 @@ import { hasUnpushedCommits, worktreeExists, } from "../utils/git"; -import { resolveWorktreePathOrThrow } from "../utils/repair-worktree-path"; +import { + getTrackedWorktreeRepairMessage, + type ResolveTrackedWorktreePathResult, + resolveTrackedWorktreePath, +} from "../utils/repair-worktree-path"; import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown"; -async function getTrackedWorktreePath( +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 { - return (await resolveWorktreePathOrThrow(worktree.id)) ?? worktree.path; +): 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 = () => { @@ -41,7 +125,7 @@ export const createDeleteProcedures = () => { }), ) .query(async ({ input }) => { - const workspace = getWorkspace(input.id); + const workspace = __testOnlyDeleteProcedureDeps.getWorkspace(input.id); if (!workspace) { return { @@ -65,7 +149,8 @@ export const createDeleteProcedures = () => { }; } - const activeTerminalCount = await getWorkspaceRuntimeRegistry() + const activeTerminalCount = await __testOnlyDeleteProcedureDeps + .getWorkspaceRuntimeRegistry() .getForWorkspaceId(input.id) .terminal.getSessionCountByWorkspaceId(input.id); @@ -94,14 +179,30 @@ 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 worktreePath = await getTrackedWorktreePath(worktree); - 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, worktreePath, ); @@ -120,8 +221,8 @@ export const createDeleteProcedures = () => { } const [hasChanges, unpushedCommits] = await Promise.all([ - hasUncommittedChanges(worktreePath), - hasUnpushedCommits(worktreePath), + __testOnlyDeleteProcedureDeps.hasUncommittedChanges(worktreePath), + __testOnlyDeleteProcedureDeps.hasUnpushedCommits(worktreePath), ]); return { @@ -165,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" }; @@ -175,167 +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; - let worktreePath: string | 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); - worktreePath = worktree - ? await getTrackedWorktreePath(worktree) - : undefined; + const terminalPromise = __testOnlyDeleteProcedureDeps + .getWorkspaceRuntimeRegistry() + .getForWorkspaceId(input.id) + .terminal.killByWorkspaceId(input.id); - if (worktreePath && project && existsSync(worktreePath)) { - teardownPromise = 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"}`, + 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: 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 }; }), @@ -348,7 +493,9 @@ export const createDeleteProcedures = () => { }), ) .query(async ({ input }) => { - const worktree = getWorktree(input.worktreeId); + const worktree = __testOnlyDeleteProcedureDeps.getWorktree( + input.worktreeId, + ); if (!worktree) { return { @@ -360,7 +507,9 @@ export const createDeleteProcedures = () => { }; } - const project = getProject(worktree.projectId); + const project = __testOnlyDeleteProcedureDeps.getProject( + worktree.projectId, + ); if (!project) { return { @@ -384,8 +533,21 @@ export const createDeleteProcedures = () => { } try { - const worktreePath = await getTrackedWorktreePath(worktree); - 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, worktreePath, ); @@ -403,8 +565,8 @@ export const createDeleteProcedures = () => { } const [hasChanges, unpushedCommits] = await Promise.all([ - hasUncommittedChanges(worktreePath), - hasUnpushedCommits(worktreePath), + __testOnlyDeleteProcedureDeps.hasUncommittedChanges(worktreePath), + __testOnlyDeleteProcedureDeps.hasUnpushedCommits(worktreePath), ]); return { @@ -434,34 +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 worktreePath = await getTrackedWorktreePath(worktree); - const exists = await worktreeExists( - project.mainRepoPath, - worktreePath, - ); + const pathResolution = + await resolveTrackedWorktreePathForCleanup(worktree); + const worktreePath = pathResolution.path; - if (exists) { - const teardownResult = await runTeardown({ - mainRepoPath: project.mainRepoPath, - worktreePath, - 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( @@ -478,27 +649,28 @@ export const createDeleteProcedures = () => { } } - if (exists) { - const removeResult = await removeWorktreeFromDisk({ + const removeResult = + await __testOnlyDeleteProcedureDeps.removeWorktreeFromDisk({ mainRepoPath: project.mainRepoPath, 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/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/repair-worktree-path.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.test.ts index a3900b17b42..56d2e157424 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 @@ -1,4 +1,11 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, +} from "bun:test"; import { execSync } from "node:child_process"; import { existsSync, @@ -9,6 +16,16 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + __testOnlyRepairWorktreePathDeps, + findProjectWorktreeByCurrentPath, + getTrackedWorktreeRepairCommand, + listProjectWorktreesWithCurrentPaths, + resolveTrackedWorktreePath, + resolveWorktreePathOrThrow, + resolveWorktreePathWithRepair, + tryRepairWorktreePath, +} from "./repair-worktree-path"; // --------------------------------------------------------------------------- // Test helpers – real git repos on disk @@ -60,77 +77,63 @@ interface MockProject { 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/schema", () => ({ - 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: (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 []; - }, - }), +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()); + return Array.from(mockWorktrees.values()).filter( + (worktree) => worktree.projectId === value, + ); } if (table === PROJECTS_TABLE) { - return Array.from(mockProjects.values()); + 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: symbol) => ({ - set: (values: { path?: string }) => ({ - where: (id: string) => ({ - run: () => { - const wt = mockWorktrees.get(id); - if (wt && values.path) wt.path = values.path; - }, - }), + }), + 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; + }, }), }), - }, -})); - -// Import after mocks are registered -const { - findProjectWorktreeByCurrentPath, - getTrackedWorktreeRepairCommand, - listProjectWorktreesWithCurrentPaths, - resolveTrackedWorktreePath, - resolveWorktreePathOrThrow, - resolveWorktreePathWithRepair, - tryRepairWorktreePath, -} = await import("./repair-worktree-path"); + }), +}; // --------------------------------------------------------------------------- // Tests @@ -141,6 +144,13 @@ describe("repair-worktree-path", () => { mkdirSync(TEST_DIR, { recursive: true }); mockWorktrees = new Map(); mockProjects = new Map(); + __testOnlyRepairWorktreePathDeps.eq = (_field, value) => value; + __testOnlyRepairWorktreePathDeps.localDb = + mockLocalDb as typeof __testOnlyRepairWorktreePathDeps.localDb; + __testOnlyRepairWorktreePathDeps.projects = + PROJECTS_TABLE as typeof __testOnlyRepairWorktreePathDeps.projects; + __testOnlyRepairWorktreePathDeps.worktrees = + WORKTREES_TABLE as typeof __testOnlyRepairWorktreePathDeps.worktrees; }); afterEach(() => { @@ -152,6 +162,10 @@ describe("repair-worktree-path", () => { } }); + afterAll(() => { + Object.assign(__testOnlyRepairWorktreePathDeps, originalDeps); + }); + test("returns null when worktree record is missing", async () => { expect(await tryRepairWorktreePath("nonexistent")).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 763a81b913f..8d7e095603b 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 @@ -38,6 +38,24 @@ interface TrackedWorktreeContext { worktree: SelectWorktree; } +interface RepairWorktreePathDeps { + eq: typeof eq; + getBranchWorktreePath: typeof getBranchWorktreePath; + localDb: typeof localDb; + projects: typeof projects; + repairWorktreeRegistration: typeof repairWorktreeRegistration; + worktrees: typeof worktrees; +} + +export const __testOnlyRepairWorktreePathDeps: RepairWorktreePathDeps = { + eq, + getBranchWorktreePath, + localDb, + projects, + repairWorktreeRegistration, + worktrees, +}; + function buildResolvedResult(path: string): ResolveTrackedWorktreePathResult { return { status: "resolved", @@ -299,7 +317,7 @@ async function tryAutoRepairTrackedWorktree(input: { `[repair-worktree-path] Found manually moved worktree for branch ${input.context.worktree.branch} at "${candidatePath}", repairing Git registration`, ); try { - await repairWorktreeRegistration({ + await __testOnlyRepairWorktreePathDeps.repairWorktreeRegistration({ mainRepoPath: input.context.mainRepoPath, worktreePath: candidatePath, }); @@ -313,10 +331,12 @@ async function tryAutoRepairTrackedWorktree(input: { let repairedPath: string | null = null; try { - repairedPath = await getBranchWorktreePath({ - mainRepoPath: input.context.mainRepoPath, - branch: input.context.worktree.branch, - }); + 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}:`, @@ -345,20 +365,30 @@ export function getTrackedWorktreeRepairMessage(input: { function getTrackedWorktreeContext( worktreeId: string, ): TrackedWorktreeContext | null { - const worktree = localDb + const worktree = __testOnlyRepairWorktreePathDeps.localDb .select() - .from(worktrees) - .where(eq(worktrees.id, worktreeId)) + .from(__testOnlyRepairWorktreePathDeps.worktrees) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.worktrees.id, + worktreeId, + ), + ) .get(); if (!worktree) { return null; } - const project = localDb + const project = __testOnlyRepairWorktreePathDeps.localDb .select() - .from(projects) - .where(eq(projects.id, worktree.projectId)) + .from(__testOnlyRepairWorktreePathDeps.projects) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.projects.id, + worktree.projectId, + ), + ) .get(); if (!project) { @@ -390,10 +420,15 @@ function persistResolvedTrackedWorktreePath(input: { console.log( `[repair-worktree-path] Worktree path changed: "${input.context.worktree.path}" → "${input.resolvedPath}" (branch: ${input.context.worktree.branch})`, ); - localDb - .update(worktrees) + __testOnlyRepairWorktreePathDeps.localDb + .update(__testOnlyRepairWorktreePathDeps.worktrees) .set({ path: input.resolvedPath }) - .where(eq(worktrees.id, input.context.worktree.id)) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.worktrees.id, + input.context.worktree.id, + ), + ) .run(); } @@ -404,7 +439,7 @@ async function getRegisteredTrackedWorktreePath( context: TrackedWorktreeContext, ): Promise { try { - return await getBranchWorktreePath({ + return await __testOnlyRepairWorktreePathDeps.getBranchWorktreePath({ mainRepoPath: context.mainRepoPath, branch: context.worktree.branch, }); @@ -566,10 +601,15 @@ export async function listProjectWorktreesWithCurrentPaths( existsOnDisk: boolean; }> > { - const projectWorktrees = localDb + const projectWorktrees = __testOnlyRepairWorktreePathDeps.localDb .select() - .from(worktrees) - .where(eq(worktrees.projectId, projectId)) + .from(__testOnlyRepairWorktreePathDeps.worktrees) + .where( + __testOnlyRepairWorktreePathDeps.eq( + __testOnlyRepairWorktreePathDeps.worktrees.projectId, + projectId, + ), + ) .all(); return Promise.all(projectWorktrees.map(resolveTrackedWorktree)); From 01bafa8add147685da852ec8d987525aaa4a1a58 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Mar 2026 19:54:51 -0700 Subject: [PATCH 12/16] fix(desktop): normalize external worktree imports --- .../routers/workspaces/procedures/create.ts | 87 ++++---- .../workspaces/procedures/git-status.ts | 33 +--- .../utils/external-worktrees.test.ts | 187 ++++++++++++++++++ .../workspaces/utils/external-worktrees.ts | 134 +++++++++++++ 4 files changed, 366 insertions(+), 75 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.test.ts create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts 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 d0a8c12138e..74d2b379f8b 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"; @@ -10,8 +15,6 @@ import { resolveWorkspaceBaseBranch } from "../utils/base-branch"; import { setBranchBaseConfig } from "../utils/base-branch-config"; import { activateProject, - findOrphanedWorktreeByBranch, - findWorktreeWorkspaceByBranch, getBranchWorkspace, getMaxProjectChildTabOrder, getProject, @@ -19,6 +22,10 @@ import { setLastActiveWorkspace, touchWorkspace, } from "../utils/db-helpers"; +import { + listImportableExternalWorktrees, + resolveExternalWorktreeOpenTarget, +} from "../utils/external-worktrees"; import { createWorktreeFromPr, generateBranchName, @@ -28,7 +35,6 @@ import { getPrInfo, getPrLocalBranchName, listBranches, - listExternalWorktrees, type PullRequestInfo, parsePrUrl, safeCheckoutBranch, @@ -37,7 +43,6 @@ import { worktreeExists, } from "../utils/git"; import { - findProjectWorktreeByCurrentPath, listProjectWorktreesWithCurrentPaths, resolveWorktreePathOrThrow, } from "../utils/repair-worktree-path"; @@ -754,20 +759,19 @@ 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 = await findProjectWorktreeByCurrentPath( - input.projectId, - input.worktreePath, - ); - - if (existingWorktree) { + if (worktreeTarget.kind === "tracked") { + const existingWorktree = worktreeTarget.worktree; // Failed init can leave gitStatus null, which shows "Setup incomplete" UI if (!existingWorktree.gitStatus) { localDb @@ -862,11 +866,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, @@ -883,8 +887,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() @@ -893,10 +897,13 @@ 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, }); @@ -918,7 +925,7 @@ export const createCreateProcedures = () => { return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: input.worktreePath, + worktreePath: worktreeTarget.worktreePath, projectId: project.id, wasExisting: false, }; @@ -1039,37 +1046,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 - .filter((trackedWorktree) => trackedWorktree.existsOnDisk) - .map((trackedWorktree) => trackedWorktree.worktree.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, @@ -1086,15 +1077,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/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 4cc751d81a5..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,5 @@ 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"; @@ -11,11 +11,11 @@ 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"; @@ -217,31 +217,10 @@ export const createGitStatusProcedures = () => { return []; } - const allWorktrees = await listExternalWorktrees(project.mainRepoPath); - - const trackedWorktrees = await listProjectWorktreesWithCurrentPaths( - input.projectId, - ); - const trackedPaths = new Set( - trackedWorktrees - .filter((trackedWorktree) => trackedWorktree.existsOnDisk) - .map((trackedWorktree) => trackedWorktree.worktree.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/utils/external-worktrees.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.test.ts new file mode 100644 index 00000000000..49ee8c6dece --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.test.ts @@ -0,0 +1,187 @@ +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 () => []); + +describe("external-worktrees", () => { + beforeEach(() => { + findProjectWorktreeByCurrentPathMock.mockReset(); + findWorktreeWorkspaceByBranchMock.mockReset(); + findOrphanedWorktreeByBranchMock.mockReset(); + listExternalWorktreesMock.mockReset(); + listProjectWorktreesWithCurrentPathsMock.mockReset(); + + findProjectWorktreeByCurrentPathMock.mockResolvedValue(null); + findWorktreeWorkspaceByBranchMock.mockReturnValue(null); + findOrphanedWorktreeByBranchMock.mockReturnValue(null); + listExternalWorktreesMock.mockResolvedValue([]); + listProjectWorktreesWithCurrentPathsMock.mockResolvedValue([]); + + __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); + }); + + 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-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, + }); + expect(listExternalWorktreesMock).not.toHaveBeenCalled(); + }); + + test("uses Git's current path when importing an external worktree 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-old", + branch: "feat-move", + }); + + 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"]); + }); +}); 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..9d6fbf4d0be --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts @@ -0,0 +1,134 @@ +import type { SelectWorktree } from "@superset/local-db/schema"; +import { + findOrphanedWorktreeByBranch, + findWorktreeWorkspaceByBranch, +} from "./db-helpers"; +import { type ExternalWorktree, listExternalWorktrees } from "./git"; +import { + findProjectWorktreeByCurrentPath, + listProjectWorktreesWithCurrentPaths, +} from "./repair-worktree-path"; + +interface ExternalWorktreeDeps { + findOrphanedWorktreeByBranch: typeof findOrphanedWorktreeByBranch; + findProjectWorktreeByCurrentPath: typeof findProjectWorktreeByCurrentPath; + findWorktreeWorkspaceByBranch: typeof findWorktreeWorkspaceByBranch; + listExternalWorktrees: typeof listExternalWorktrees; + listProjectWorktreesWithCurrentPaths: typeof listProjectWorktreesWithCurrentPaths; +} + +export const __testOnlyExternalWorktreeDeps: ExternalWorktreeDeps = { + findOrphanedWorktreeByBranch, + findProjectWorktreeByCurrentPath, + findWorktreeWorkspaceByBranch, + listExternalWorktrees, + listProjectWorktreesWithCurrentPaths, +}; + +export type ExternalWorktreeOpenTarget = + | { + kind: "tracked"; + worktree: SelectWorktree; + } + | { + kind: "external"; + worktreePath: string; + branch: string; + }; + +function isImportableExternalWorktree( + worktree: ExternalWorktree, + mainRepoPath: string, +): worktree is ExternalWorktree & { branch: string } { + return ( + worktree.path !== mainRepoPath && + !worktree.isBare && + !worktree.isDetached && + Boolean(worktree.branch) + ); +} + +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: trackedWorktree, + }; + } + + const externalWorktrees = + await __testOnlyExternalWorktreeDeps.listExternalWorktrees( + input.mainRepoPath, + ); + const matchingExternalWorktree = externalWorktrees.find( + (worktree) => + isImportableExternalWorktree(worktree, input.mainRepoPath) && + (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 + .filter((trackedWorktree) => trackedWorktree.existsOnDisk) + .map((trackedWorktree) => trackedWorktree.worktree.path), + ); + + const externalWorktrees = + await __testOnlyExternalWorktreeDeps.listExternalWorktrees( + input.mainRepoPath, + ); + + return externalWorktrees + .filter( + (worktree) => + isImportableExternalWorktree(worktree, input.mainRepoPath) && + !trackedPaths.has(worktree.path), + ) + .map((worktree) => ({ + path: worktree.path, + branch: worktree.branch, + })); +} From f64eeb5f45d59a1a0b7203cce526fb365628ab47 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Mar 2026 23:26:44 -0700 Subject: [PATCH 13/16] fix(desktop): use resolved branch for external imports --- .../routers/workspaces/procedures/create.ts | 6 ++-- .../utils/external-worktrees.test.ts | 6 ++-- .../workspaces/utils/external-worktrees.ts | 31 ++++++++++++------- 3 files changed, 27 insertions(+), 16 deletions(-) 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 74d2b379f8b..b2e026bd5b3 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -15,6 +15,8 @@ import { resolveWorkspaceBaseBranch } from "../utils/base-branch"; import { setBranchBaseConfig } from "../utils/base-branch-config"; import { activateProject, + findOrphanedWorktreeByBranch, + findWorktreeWorkspaceByBranch, getBranchWorkspace, getMaxProjectChildTabOrder, getProject, @@ -910,14 +912,14 @@ export const createCreateProcedures = () => { 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, }); 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 index 49ee8c6dece..b7db89b072a 100644 --- 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 @@ -123,7 +123,7 @@ describe("external-worktrees", () => { expect(listExternalWorktreesMock).not.toHaveBeenCalled(); }); - test("uses Git's current path when importing an external worktree from a stale request", async () => { + test("uses Git's current branch when importing by path from a stale request", async () => { listExternalWorktreesMock.mockResolvedValue([ { path: "/repo/wt-new", @@ -136,8 +136,8 @@ describe("external-worktrees", () => { const result = await resolveExternalWorktreeOpenTarget({ projectId: "proj-1", mainRepoPath: "/repo/main", - worktreePath: "/repo/wt-old", - branch: "feat-move", + worktreePath: "/repo/wt-new", + branch: "feat-stale", }); 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 index 9d6fbf4d0be..c52047efef2 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts @@ -36,10 +36,12 @@ export type ExternalWorktreeOpenTarget = branch: string; }; +type ImportableExternalWorktree = ExternalWorktree & { branch: string }; + function isImportableExternalWorktree( worktree: ExternalWorktree, mainRepoPath: string, -): worktree is ExternalWorktree & { branch: string } { +): worktree is ImportableExternalWorktree { return ( worktree.path !== mainRepoPath && !worktree.isBare && @@ -48,6 +50,16 @@ function isImportableExternalWorktree( ); } +function getImportableExternalWorktrees( + externalWorktrees: ExternalWorktree[], + mainRepoPath: string, +): ImportableExternalWorktree[] { + return externalWorktrees.filter( + (worktree): worktree is ImportableExternalWorktree => + isImportableExternalWorktree(worktree, mainRepoPath), + ); +} + export async function resolveExternalWorktreeOpenTarget(input: { projectId: string; mainRepoPath: string; @@ -79,11 +91,12 @@ export async function resolveExternalWorktreeOpenTarget(input: { await __testOnlyExternalWorktreeDeps.listExternalWorktrees( input.mainRepoPath, ); - const matchingExternalWorktree = externalWorktrees.find( + const matchingExternalWorktree = getImportableExternalWorktrees( + externalWorktrees, + input.mainRepoPath, + ).find( (worktree) => - isImportableExternalWorktree(worktree, input.mainRepoPath) && - (worktree.path === input.worktreePath || - worktree.branch === input.branch), + worktree.path === input.worktreePath || worktree.branch === input.branch, ); if (!matchingExternalWorktree) { @@ -121,12 +134,8 @@ export async function listImportableExternalWorktrees(input: { input.mainRepoPath, ); - return externalWorktrees - .filter( - (worktree) => - isImportableExternalWorktree(worktree, input.mainRepoPath) && - !trackedPaths.has(worktree.path), - ) + return getImportableExternalWorktrees(externalWorktrees, input.mainRepoPath) + .filter((worktree) => !trackedPaths.has(worktree.path)) .map((worktree) => ({ path: worktree.path, branch: worktree.branch, From 08d4a6876c812aefaaf5a1973423bed293ada0f7 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 10 Mar 2026 14:53:51 -0700 Subject: [PATCH 14/16] fix(desktop): avoid duplicate external worktree imports --- .../routers/workspaces/procedures/create.ts | 10 +++-- .../utils/external-worktrees.test.ts | 43 ++++++++++++++++++- .../workspaces/utils/external-worktrees.ts | 36 +++++++++++++--- 3 files changed, 78 insertions(+), 11 deletions(-) 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 b2e026bd5b3..7a63031b16b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -774,6 +774,8 @@ export const createCreateProcedures = () => { 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 @@ -808,7 +810,7 @@ export const createCreateProcedures = () => { return { workspace: existingWorkspace, initialCommands: null, - worktreePath: existingWorktree.path, + worktreePath: trackedWorktreePath, projectId: project.id, wasExisting: true, }; @@ -833,11 +835,11 @@ export const createCreateProcedures = () => { copySupersetConfigToWorktree( project.mainRepoPath, - existingWorktree.path, + trackedWorktreePath, ); const setupConfig = loadSetupConfig({ mainRepoPath: project.mainRepoPath, - worktreePath: existingWorktree.path, + worktreePath: trackedWorktreePath, projectId: project.id, }); @@ -851,7 +853,7 @@ export const createCreateProcedures = () => { return { workspace, initialCommands: setupConfig?.setup || null, - worktreePath: existingWorktree.path, + worktreePath: trackedWorktreePath, projectId: project.id, wasExisting: false, }; 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 index b7db89b072a..4df576baba4 100644 --- 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 @@ -66,6 +66,9 @@ const listExternalWorktreesMock = mock< const listProjectWorktreesWithCurrentPathsMock = mock< typeof __testOnlyExternalWorktreeDeps.listProjectWorktreesWithCurrentPaths >(async () => []); +const resolveWorktreePathWithRepairMock = mock< + typeof __testOnlyExternalWorktreeDeps.resolveWorktreePathWithRepair +>(async () => null); describe("external-worktrees", () => { beforeEach(() => { @@ -74,12 +77,14 @@ describe("external-worktrees", () => { 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 @@ -93,6 +98,8 @@ describe("external-worktrees", () => { __testOnlyExternalWorktreeDeps.listProjectWorktreesWithCurrentPaths = ( ...args ) => listProjectWorktreesWithCurrentPathsMock(...args); + __testOnlyExternalWorktreeDeps.resolveWorktreePathWithRepair = (...args) => + resolveWorktreePathWithRepairMock(...args); }); afterAll(() => { @@ -101,8 +108,9 @@ describe("external-worktrees", () => { test("reuses a tracked worktree by branch when path repair changes the current path", async () => { const trackedWorktree = makeWorktree({ - path: "/repo/wt-new", + path: "/repo/wt-old", }); + resolveWorktreePathWithRepairMock.mockResolvedValue("/repo/wt-new"); findWorktreeWorkspaceByBranchMock.mockReturnValue({ workspace: makeWorkspace({ worktreeId: trackedWorktree.id }), @@ -118,7 +126,10 @@ describe("external-worktrees", () => { expect(result).toEqual({ kind: "tracked", - worktree: trackedWorktree, + worktree: { + ...trackedWorktree, + path: "/repo/wt-new", + }, }); expect(listExternalWorktreesMock).not.toHaveBeenCalled(); }); @@ -184,4 +195,32 @@ describe("external-worktrees", () => { 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 index c52047efef2..66b668a237b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/external-worktrees.ts @@ -7,6 +7,7 @@ import { type ExternalWorktree, listExternalWorktrees } from "./git"; import { findProjectWorktreeByCurrentPath, listProjectWorktreesWithCurrentPaths, + resolveWorktreePathWithRepair, } from "./repair-worktree-path"; interface ExternalWorktreeDeps { @@ -15,6 +16,7 @@ interface ExternalWorktreeDeps { findWorktreeWorkspaceByBranch: typeof findWorktreeWorkspaceByBranch; listExternalWorktrees: typeof listExternalWorktrees; listProjectWorktreesWithCurrentPaths: typeof listProjectWorktreesWithCurrentPaths; + resolveWorktreePathWithRepair: typeof resolveWorktreePathWithRepair; } export const __testOnlyExternalWorktreeDeps: ExternalWorktreeDeps = { @@ -23,6 +25,7 @@ export const __testOnlyExternalWorktreeDeps: ExternalWorktreeDeps = { findWorktreeWorkspaceByBranch, listExternalWorktrees, listProjectWorktreesWithCurrentPaths, + resolveWorktreePathWithRepair, }; export type ExternalWorktreeOpenTarget = @@ -60,6 +63,24 @@ function getImportableExternalWorktrees( ); } +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; @@ -83,7 +104,7 @@ export async function resolveExternalWorktreeOpenTarget(input: { if (trackedWorktree) { return { kind: "tracked", - worktree: trackedWorktree, + worktree: await resolveTrackedExternalWorktree(trackedWorktree), }; } @@ -124,9 +145,10 @@ export async function listImportableExternalWorktrees(input: { input.projectId, ); const trackedPaths = new Set( - trackedWorktrees - .filter((trackedWorktree) => trackedWorktree.existsOnDisk) - .map((trackedWorktree) => trackedWorktree.worktree.path), + trackedWorktrees.map((trackedWorktree) => trackedWorktree.worktree.path), + ); + const trackedBranches = new Set( + trackedWorktrees.map((trackedWorktree) => trackedWorktree.worktree.branch), ); const externalWorktrees = @@ -135,7 +157,11 @@ export async function listImportableExternalWorktrees(input: { ); return getImportableExternalWorktrees(externalWorktrees, input.mainRepoPath) - .filter((worktree) => !trackedPaths.has(worktree.path)) + .filter( + (worktree) => + !trackedPaths.has(worktree.path) && + !trackedBranches.has(worktree.branch), + ) .map((worktree) => ({ path: worktree.path, branch: worktree.branch, From a820e287c1720077bde6ae4384505e215f475eb9 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 10 Mar 2026 17:23:51 -0700 Subject: [PATCH 15/16] fix(desktop): unblock worktree repair typecheck --- .../workspaces/procedures/delete.test.ts | 209 +++++++++++++----- .../lib/trpc/routers/workspaces/utils/git.ts | 2 +- .../utils/repair-worktree-path.test.ts | 9 +- 3 files changed, 154 insertions(+), 66 deletions(-) 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 index 9e7aab42ad6..eaf67246a59 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.test.ts @@ -6,27 +6,54 @@ import { createDeleteProcedures, } from "./delete"; -interface MockWorkspace { - id: string; - projectId: string; - worktreeId: string | null; - type: "worktree" | "branch"; - name: string; - branch: string; - deletingAt: number | null; -} - -interface MockWorktree { - id: string; - projectId: string; - path: string; - branch: string; -} - -interface MockProject { - id: string; - mainRepoPath: string; -} +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 = | { @@ -63,52 +90,106 @@ const originalWorkspaceInitManagerMethods = { waitForInit: __testOnlyDeleteProcedureDeps.workspaceInitManager.waitForInit, }; -const hideProjectIfNoWorkspacesMock = mock(() => {}); -const updateActiveWorkspaceIfRemovedMock = mock(() => {}); -const killByWorkspaceIdMock = mock(async () => ({ 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<(worktreeId: string) => Promise>(); -const runTeardownMock = mock(async () => ({ success: true as const })); -const removeWorktreeFromDiskMock = mock(async () => ({ +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 createCaller() { - return createDeleteProcedures().createCaller({}); -} - -function seedTrackedWorkspace() { - projects.set("proj-1", { +function createProject(overrides: Partial = {}): MockProject { + return { id: "proj-1", mainRepoPath: "/repo/main", - }); - worktrees.set("wt-1", { + 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", - }); - workspaces.set("ws-1", { + 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 { @@ -147,7 +228,7 @@ describe("delete procedures", () => { removeWorktreeFromDiskMock.mockClear(); isInitializingMock.mockReturnValue(false); - killByWorkspaceIdMock.mockResolvedValue({ failed: 0 }); + killByWorkspaceIdMock.mockResolvedValue({ killed: 0, failed: 0 }); getSessionCountByWorkspaceIdMock.mockResolvedValue(0); waitForInitMock.mockResolvedValue(undefined); acquireProjectLockMock.mockResolvedValue(undefined); @@ -186,15 +267,21 @@ describe("delete procedures", () => { projects.get(projectId); __testOnlyDeleteProcedureDeps.getWorkspace = (workspaceId: string) => workspaces.get(workspaceId); - __testOnlyDeleteProcedureDeps.getWorkspaceRuntimeRegistry = () => ({ - getForWorkspaceId: () => ({ - terminal: { - killByWorkspaceId: (...args) => killByWorkspaceIdMock(...args), - getSessionCountByWorkspaceId: (...args) => - getSessionCountByWorkspaceIdMock(...args), - }, - }), - }); + 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) => 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 2a6c49c810b..ab5613c2387 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -837,7 +837,7 @@ export async function repairWorktreeRegistration({ worktreePath: string; }): Promise { try { - const git = simpleGit(mainRepoPath); + const git = await getSimpleGitWithShellPath(mainRepoPath); await git.raw(["worktree", "repair", worktreePath]); } catch (error) { console.error(`Failed to repair worktree registration: ${error}`); 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 56d2e157424..be62f47f3a9 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 @@ -144,13 +144,14 @@ describe("repair-worktree-path", () => { mkdirSync(TEST_DIR, { recursive: true }); mockWorktrees = new Map(); mockProjects = new Map(); - __testOnlyRepairWorktreePathDeps.eq = (_field, value) => value; + __testOnlyRepairWorktreePathDeps.eq = ((_: unknown, value: string) => + value) as unknown as typeof __testOnlyRepairWorktreePathDeps.eq; __testOnlyRepairWorktreePathDeps.localDb = - mockLocalDb as typeof __testOnlyRepairWorktreePathDeps.localDb; + mockLocalDb as unknown as typeof __testOnlyRepairWorktreePathDeps.localDb; __testOnlyRepairWorktreePathDeps.projects = - PROJECTS_TABLE as typeof __testOnlyRepairWorktreePathDeps.projects; + PROJECTS_TABLE as unknown as typeof __testOnlyRepairWorktreePathDeps.projects; __testOnlyRepairWorktreePathDeps.worktrees = - WORKTREES_TABLE as typeof __testOnlyRepairWorktreePathDeps.worktrees; + WORKTREES_TABLE as unknown as typeof __testOnlyRepairWorktreePathDeps.worktrees; }); afterEach(() => { From 386cebb4b2e4ee054017e4d0ecd79b4b6437c6e9 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 10 Mar 2026 23:23:39 -0700 Subject: [PATCH 16/16] perf(desktop): move worktree repair off sidebar hot path --- .../src/lib/trpc/routers/terminal/terminal.ts | 10 +- .../routers/workspaces/procedures/query.ts | 112 +++++- .../utils/repair-worktree-path.test.ts | 86 +++++ .../workspaces/utils/repair-worktree-path.ts | 336 ++++++++++++++++-- .../ProjectSection/ProjectSection.tsx | 8 + .../WorkspaceList/WorkspaceList.tsx | 4 + .../WorkspaceListItem/WorkspaceListItem.tsx | 46 ++- .../main/components/WorkspaceSidebar/types.ts | 4 + .../Terminal/hooks/useTerminalConnection.ts | 12 +- .../ContentView/TabsContent/Terminal/types.ts | 1 + 10 files changed, 557 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 2eddf50f09e..00ce1bd9f1b 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -16,7 +16,7 @@ import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { - resolveWorktreePathOrThrow, + resolveWorktreePathOrThrowWithMetadata, resolveWorktreePathWithRepair, } from "../workspaces/utils/repair-worktree-path"; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; @@ -113,10 +113,13 @@ 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 ? workspace.type === "worktree" && workspace.worktreeId - ? ((await resolveWorktreePathOrThrow(workspace.worktreeId)) ?? - undefined) + ? (worktreeResolution?.path ?? undefined) : (getWorkspacePath(workspace) ?? undefined) : undefined; @@ -190,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) 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 1602cd7d956..2269563be00 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -12,22 +12,73 @@ import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getWorkspace } from "../utils/db-helpers"; import { getProjectChildItems } from "../utils/project-children-order"; -import { resolveWorktreePathWithRepair } from "../utils/repair-worktree-path"; +import { + getTrackedWorktreeDisplayStateFromTrackedWorktree, + resolveWorktreePathWithRepairMetadata, + type TrackedWorktreeRepairState, +} from "../utils/repair-worktree-path"; import { computeVisualOrder } from "../utils/visual-order"; import { getWorkspacePath } from "../utils/worktree"; -async function getWorkspacePathForQuery( - workspace: SelectWorkspace, -): Promise { +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 getWorkspacePath(workspace); + return { + existsOnDisk: true, + repairCommand: null, + repairMessage: null, + repairState: "ok", + worktreePath: getWorkspacePath(workspace) ?? "", + }; } - if (!workspace.worktreeId) { - return null; + if (!workspace.worktreeId || !worktree || !projectMainRepoPath) { + return { + existsOnDisk: false, + repairCommand: null, + repairMessage: "Tracked worktree could not be found.", + repairState: "missing", + worktreePath: "", + }; } - return resolveWorktreePathWithRepair(workspace.worktreeId); + 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). */ @@ -68,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: (await getWorkspacePathForQuery(workspace)) ?? "", + worktreePath: queryState.worktreePath, project: project ? { id: project.id, @@ -113,6 +174,10 @@ export const createQueryProcedures = () => { 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; @@ -205,22 +270,31 @@ export const createQueryProcedures = () => { .where(isNull(workspaces.deletingAt)) .all() .sort((a, b) => a.tabOrder - b.tabOrder); - - const workspacesWithResolvedPaths = await Promise.all( - allWorkspaces.map(async (workspace) => ({ - workspace, - worktreePath: (await getWorkspacePathForQuery(workspace)) ?? "", - })), + const allWorktrees = localDb.select().from(worktrees).all(); + const worktreeMap = new Map( + allWorktrees.map((worktree) => [worktree.id, worktree]), ); - for (const { workspace, worktreePath } of workspacesWithResolvedPaths) { + for (const workspace of allWorkspaces) { const group = groupsMap.get(workspace.projectId); if (group) { + 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, }; 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 be62f47f3a9..e682c393a14 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 @@ -19,11 +19,13 @@ import { join } from "node:path"; import { __testOnlyRepairWorktreePathDeps, findProjectWorktreeByCurrentPath, + getTrackedWorktreeDisplayState, getTrackedWorktreeRepairCommand, listProjectWorktreesWithCurrentPaths, resolveTrackedWorktreePath, resolveWorktreePathOrThrow, resolveWorktreePathWithRepair, + resolveWorktreePathWithRepairMetadata, tryRepairWorktreePath, } from "./repair-worktree-path"; @@ -276,6 +278,39 @@ describe("repair-worktree-path", () => { 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); @@ -481,5 +516,56 @@ describe("repair-worktree-path", () => { 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 index 8d7e095603b..6681eb4dca3 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 @@ -33,6 +33,21 @@ export type ResolveTrackedWorktreePathResult = 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; @@ -47,6 +62,16 @@ interface RepairWorktreePathDeps { worktrees: typeof worktrees; } +interface ResolveTrackedWorktreePathWithMetadataResult { + pathChanged: boolean; + resolution: ResolveTrackedWorktreePathResult; +} + +interface CachedRepairFailure { + recordedAt: number; + resolution: Exclude; +} + export const __testOnlyRepairWorktreePathDeps: RepairWorktreePathDeps = { eq, getBranchWorktreePath, @@ -63,14 +88,20 @@ function buildResolvedResult(path: string): ResolveTrackedWorktreePathResult { }; } -function buildMissingResolutionResult(): ResolveTrackedWorktreePathResult { +function buildMissingResolutionResult(): Extract< + ResolveTrackedWorktreePathResult, + { status: "missing" } +> { return { status: "missing" }; } function buildGitRepairRequiredResolution( context: TrackedWorktreeContext, registeredPath: string, -): ResolveTrackedWorktreePathResult { +): Extract< + ResolveTrackedWorktreePathResult, + { status: "git_repair_required" } +> { return { status: "git_repair_required", branch: context.worktree.branch, @@ -80,8 +111,9 @@ function buildGitRepairRequiredResolution( }; } -const MAX_SEARCH_DEPTH = 2; -const MAX_SCAN_DIRS = 1500; +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", @@ -91,6 +123,11 @@ const SKIPPED_SCAN_DIRS = new Set([ "coverage", "target", ]); +const cachedRepairFailures = new Map(); +const activeRepairAttempts = new Map< + string, + Promise +>(); function readDirectoryEntries(path: string): Dirent[] { return readdirSync(path, { withFileTypes: true }); @@ -150,7 +187,7 @@ function getTrackedWorktreeSearchRoots( ): string[] { const roots = [ dirname(context.worktree.path), - dirname(dirname(context.worktree.path)), + context.mainRepoPath, dirname(context.mainRepoPath), ]; @@ -351,6 +388,36 @@ async function tryAutoRepairTrackedWorktree(input: { 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 `; } @@ -362,6 +429,59 @@ export function getTrackedWorktreeRepairMessage(input: { 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 { @@ -401,6 +521,34 @@ function getTrackedWorktreeContext( }; } +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, @@ -411,12 +559,16 @@ function isMainRepoPath( function persistResolvedTrackedWorktreePath(input: { context: TrackedWorktreeContext; resolvedPath: string; -}): ResolveTrackedWorktreePathResult { +}): ResolveTrackedWorktreePathWithMetadataResult { if (isMainRepoPath(input.context, input.resolvedPath)) { - return buildMissingResolutionResult(); + const resolution = buildMissingResolutionResult(); + rememberRepairFailure(input.context.worktree.id, resolution); + return { pathChanged: false, resolution }; } - if (input.resolvedPath !== input.context.worktree.path) { + 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})`, ); @@ -432,7 +584,12 @@ function persistResolvedTrackedWorktreePath(input: { .run(); } - return buildResolvedResult(input.resolvedPath); + clearRepairFailure(input.context.worktree.id); + + return { + pathChanged, + resolution: buildResolvedResult(input.resolvedPath), + }; } async function getRegisteredTrackedWorktreePath( @@ -454,11 +611,13 @@ async function getRegisteredTrackedWorktreePath( async function resolveTrackedWorktreePathFromGitState( context: TrackedWorktreeContext, -): Promise { +): Promise { const registeredPath = await getRegisteredTrackedWorktreePath(context); if (!registeredPath) { - return buildMissingResolutionResult(); + const resolution = buildMissingResolutionResult(); + rememberRepairFailure(context.worktree.id, resolution); + return { pathChanged: false, resolution }; } if (existsSync(registeredPath)) { @@ -468,12 +627,25 @@ async function resolveTrackedWorktreePathFromGitState( }); } + const cachedFailure = getCachedRepairFailure(context.worktree.id); + if (cachedFailure) { + return { + pathChanged: false, + resolution: cachedFailure.resolution, + }; + } + const repairedPath = await tryAutoRepairTrackedWorktree({ context, }); if (!repairedPath) { - return buildGitRepairRequiredResolution(context, registeredPath); + const resolution = buildGitRepairRequiredResolution( + context, + registeredPath, + ); + rememberRepairFailure(context.worktree.id, resolution); + return { pathChanged: false, resolution }; } return persistResolvedTrackedWorktreePath({ @@ -482,6 +654,40 @@ async function resolveTrackedWorktreePathFromGitState( }); } +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 { @@ -491,46 +697,78 @@ function getResolvedTrackedWorktreePath( export async function resolveTrackedWorktreePath( worktreeId: string, ): Promise { - const context = getTrackedWorktreeContext(worktreeId); - if (!context) { - return buildMissingResolutionResult(); + 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 (existsSync(context.worktree.path)) { - return buildResolvedResult(context.worktree.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 resolveTrackedWorktreePathFromGitState(context); + return null; } -export async function resolveWorktreePathOrThrow( +export async function resolveWorktreePathOrThrowWithMetadata( worktreeId: string, -): Promise { - const resolution = await resolveTrackedWorktreePath(worktreeId); +): Promise<{ + path: string | null; + pathChanged: boolean; +}> { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); - if (resolution.status === "resolved") { - return resolution.path; + if (resolution.resolution.status === "resolved") { + return { + path: resolution.resolution.path, + pathChanged: resolution.pathChanged, + }; } - if (resolution.status === "git_repair_required") { + if (resolution.resolution.status === "git_repair_required") { throw new TRPCError({ code: "PRECONDITION_FAILED", message: getTrackedWorktreeRepairMessage({ - branch: resolution.branch, - mainRepoPath: resolution.mainRepoPath, + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, }), cause: { reason: "git_repair_required", - branch: resolution.branch, - mainRepoPath: resolution.mainRepoPath, - registeredPath: resolution.registeredPath, - storedPath: resolution.storedPath, - command: getTrackedWorktreeRepairCommand(resolution.mainRepoPath), + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + registeredPath: resolution.resolution.registeredPath, + storedPath: resolution.resolution.storedPath, + command: getTrackedWorktreeRepairCommand( + resolution.resolution.mainRepoPath, + ), }, }); } - return null; + return { path: null, pathChanged: false }; } /** @@ -558,8 +796,38 @@ export async function tryRepairWorktreePath( export async function resolveWorktreePathWithRepair( worktreeId: string, ): Promise { - const resolution = await resolveTrackedWorktreePath(worktreeId); - return getResolvedTrackedWorktreePath(resolution); + 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( 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 && ( { createOrAttachMutation.mutate(input, { onSuccess: (data) => { - void Promise.all([ - utils.workspaces.get.invalidate({ id: workspaceId }), - utils.workspaces.getAllGrouped.invalidate(), - utils.terminal.getWorkspaceCwd.invalidate(workspaceId), - ]); + 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) => { 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;