diff --git a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts new file mode 100644 index 000000000..503707e71 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts @@ -0,0 +1,141 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, +} from "../workspace-registry.js"; + +const cleanupPaths = new Set(); +const cleanupDaemons = new Set(); +const cleanupClients = new Set(); + +afterEach(async () => { + await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined))); + cleanupClients.clear(); + await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined))); + cleanupDaemons.clear(); + await Promise.all( + Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })), + ); + cleanupPaths.clear(); +}); + +test("openProject reclassifies an existing directory workspace into its parent git project", async () => { + const previousSupervised = process.env.PASEO_SUPERVISED; + process.env.PASEO_SUPERVISED = "0"; + try { + const repoRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-open-project-repo-"))); + const worktreeRoot = realpathSync( + mkdtempSync(path.join(os.tmpdir(), "paseo-open-project-worktree-")), + ); + const paseoHomeRoot = realpathSync( + mkdtempSync(path.join(os.tmpdir(), "paseo-open-project-home-")), + ); + cleanupPaths.add(repoRoot); + cleanupPaths.add(worktreeRoot); + cleanupPaths.add(paseoHomeRoot); + + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@getpaseo.dev'", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.name 'Paseo Test'", { cwd: repoRoot, stdio: "pipe" }); + writeFileSync(path.join(repoRoot, "README.md"), "# repo\n", "utf8"); + execSync("git add README.md", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgSign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" }); + execSync("git branch feature/desktop-daemon-settings", { cwd: repoRoot, stdio: "pipe" }); + execSync(`git worktree add ${JSON.stringify(worktreeRoot)} feature/desktop-daemon-settings`, { + cwd: repoRoot, + stdio: "pipe", + }); + + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const projectsPath = path.join(paseoHome, "projects", "projects.json"); + const workspacesPath = path.join(paseoHome, "projects", "workspaces.json"); + const timestamp = "2026-04-24T09:46:43.146Z"; + + await mkdir(path.dirname(projectsPath), { recursive: true }); + await writeRegistry(projectsPath, [ + createPersistedProjectRecord({ + projectId: repoRoot, + rootPath: repoRoot, + kind: "git", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + createPersistedProjectRecord({ + projectId: worktreeRoot, + rootPath: worktreeRoot, + kind: "non_git", + displayName: "desktop-daemon-settings", + createdAt: timestamp, + updatedAt: timestamp, + }), + ]); + await writeRegistry(workspacesPath, [ + createPersistedWorkspaceRecord({ + workspaceId: repoRoot, + projectId: repoRoot, + cwd: repoRoot, + kind: "local_checkout", + displayName: "main", + createdAt: timestamp, + updatedAt: timestamp, + }), + createPersistedWorkspaceRecord({ + workspaceId: worktreeRoot, + projectId: worktreeRoot, + cwd: worktreeRoot, + kind: "directory", + displayName: "desktop-daemon-settings", + createdAt: timestamp, + updatedAt: timestamp, + }), + ]); + + const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false }); + cleanupDaemons.add(daemon); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "worktree-reclassification" } }); + + const response = await client.openProject(worktreeRoot); + const persistedProjects = await readRegistry(projectsPath); + const persistedWorkspaces = await readRegistry(workspacesPath); + + expect(response.error).toBeNull(); + expect(response.workspace?.projectId).toBe(repoRoot); + expect(response.workspace?.workspaceKind).toBe("worktree"); + expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe( + repoRoot, + ); + expect( + persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId, + ).toBe(repoRoot); + expect( + persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.kind, + ).toBe("worktree"); + } finally { + process.env.PASEO_SUPERVISED = previousSupervised; + } +}, 30_000); + +async function writeRegistry( + filePath: string, + records: PersistedProjectRecord[] | PersistedWorkspaceRecord[], +): Promise { + await writeFile(filePath, JSON.stringify(records, null, 2), "utf8"); +} + +async function readRegistry(filePath: string): Promise { + return JSON.parse(await readFile(filePath, "utf8")) as TRecord[]; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b55455355..67e66f721 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -137,12 +137,8 @@ import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId as normalizePersistedWorkspaceId, deriveProjectGroupingName, - deriveWorkspaceId, - deriveProjectRootPath, - deriveProjectKind, - deriveWorkspaceKind, + classifyDirectoryForProjectMembership, deriveWorkspaceDisplayName, - buildProjectPlacementForCwd as buildProjectPlacementForCwdStandalone, } from "./workspace-registry-model.js"; import { createPersistedProjectRecord, @@ -1535,6 +1531,15 @@ export class Session { return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null; } + private async findExactWorkspaceByDirectory( + cwd: string, + options?: { refreshGit?: boolean }, + ): Promise { + const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options); + const workspaces = await this.workspaceRegistry.list(); + return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null; + } + private async resolveWorkspaceDirectory( cwd: string, options?: { refreshGit?: boolean }, @@ -4569,6 +4574,7 @@ export class Session { cwd, isGit: true, repoRoot: snapshot.git.repoRoot, + mainRepoRoot: snapshot.git.mainRepoRoot, currentBranch: snapshot.git.currentBranch ?? null, isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef ?? null, @@ -6338,37 +6344,39 @@ export class Session { private async findOrCreateWorkspaceForDirectory(cwd: string): Promise { const normalizedCwd = await this.resolveWorkspaceDirectory(cwd); - const existingWorkspace = await this.findWorkspaceByDirectory(normalizedCwd); + const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, { + refreshGit: false, + }); if (existingWorkspace) { - return this.ensureWorkspaceRecordUnarchived(existingWorkspace); + return this.reclassifyOrUnarchiveWorkspaceForDirectory({ + workspace: existingWorkspace, + project: await this.projectRegistry.get(existingWorkspace.projectId), + cwd: normalizedCwd, + }); } - const placement = await buildProjectPlacementForCwdStandalone({ - cwd: normalizedCwd, + return this.createWorkspaceForDirectory(normalizedCwd); + } + + private async createWorkspaceForDirectory(cwd: string): Promise { + const membership = await classifyDirectoryForProjectMembership({ + cwd, workspaceGitService: this.workspaceGitService, }); - const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); const timestamp = new Date().toISOString(); - const projectRecord = createPersistedProjectRecord({ - projectId: placement.projectKey, - rootPath: deriveProjectRootPath({ cwd: normalizedCwd, checkout: placement.checkout }), - kind: deriveProjectKind(placement.checkout), - displayName: placement.projectName, - createdAt: timestamp, - updatedAt: timestamp, + const projectRecord = await this.resolveProjectRecordForPlacement({ + membership, + timestamp, }); await this.projectRegistry.upsert(projectRecord); const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId, - projectId: placement.projectKey, - cwd: normalizedCwd, - kind: deriveWorkspaceKind(placement.checkout), - displayName: deriveWorkspaceDisplayName({ - cwd: normalizedCwd, - checkout: placement.checkout, - }), + workspaceId: membership.workspaceId, + projectId: projectRecord.projectId, + cwd, + kind: membership.workspaceKind, + displayName: membership.workspaceDisplayName, createdAt: timestamp, updatedAt: timestamp, }); @@ -6376,6 +6384,81 @@ export class Session { return workspaceRecord; } + private async reclassifyOrUnarchiveWorkspaceForDirectory(input: { + workspace: PersistedWorkspaceRecord; + project: PersistedProjectRecord | null; + cwd: string; + }): Promise { + const membership = await classifyDirectoryForProjectMembership({ + cwd: input.cwd, + workspaceGitService: this.workspaceGitService, + }); + const timestamp = new Date().toISOString(); + const projectRecord = await this.resolveProjectRecordForPlacement({ + membership, + timestamp, + }); + const projectId = projectRecord.projectId; + const kind = membership.workspaceKind; + const displayName = membership.workspaceDisplayName; + + if ( + input.workspace.workspaceId === membership.workspaceId && + input.workspace.projectId === projectId && + input.workspace.kind === kind && + input.workspace.displayName === displayName + ) { + return this.ensureWorkspaceRecordUnarchived(input.workspace); + } + + await this.projectRegistry.upsert(projectRecord); + + const nextWorkspace = { + ...input.workspace, + workspaceId: membership.workspaceId, + projectId, + cwd: input.cwd, + kind, + displayName, + archivedAt: null, + updatedAt: timestamp, + }; + await this.workspaceRegistry.upsert(nextWorkspace); + return nextWorkspace; + } + + private async resolveProjectRecordForPlacement(input: { + membership: Awaited>; + timestamp: string; + }): Promise { + const rootPath = input.membership.projectRootPath; + const kind = input.membership.projectKind; + const projects = await this.projectRegistry.list(); + const existingProject = + projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ?? + projects.find((project) => project.rootPath === rootPath) ?? + null; + + if (!existingProject) { + return createPersistedProjectRecord({ + projectId: input.membership.projectKey, + rootPath, + kind, + displayName: input.membership.projectName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); + } + + return { + ...existingProject, + rootPath, + kind, + archivedAt: null, + updatedAt: input.timestamp, + }; + } + private async ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 6ed45f873..af2a5bc7f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1160,6 +1160,10 @@ test("close_items_request continues after an archive failure", async () => { dispose: () => {}, } as unknown as SessionOptions["checkoutDiffManager"], workspaceGitService: createNoopWorkspaceGitService(), + daemonConfigStore: { + get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), + onChange: () => () => {}, + } as unknown as SessionOptions["daemonConfigStore"], mcpBaseUrl: null, stt: null, tts: null, @@ -2090,6 +2094,426 @@ test("open_project_request registers a workspace before any agent exists", async expect(response?.payload.workspace?.id).toBe("/tmp/repo"); }); +test("open_project_request does not match a new child directory to an existing parent workspace", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + const projects = new Map>(); + const workspaces = new Map>(); + const home = "/Users/moboudra"; + const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea"; + + projects.set( + home, + createPersistedProjectRecord({ + projectId: home, + rootPath: home, + kind: "non_git", + displayName: "moboudra", + createdAt: "2026-04-24T09:00:00.000Z", + updatedAt: "2026-04-24T09:00:00.000Z", + }), + ); + workspaces.set( + home, + createPersistedWorkspaceRecord({ + workspaceId: home, + projectId: home, + cwd: home, + kind: "directory", + displayName: "moboudra", + createdAt: "2026-04-24T09:00:00.000Z", + updatedAt: "2026-04-24T09:00:00.000Z", + }), + ); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + await session.handleMessage({ + type: "open_project_request", + cwd: worktree, + requestId: "req-open-worktree-under-home", + }); + + const response = emitted.find((message) => message.type === "open_project_response") as + | { payload: { error: unknown; workspace?: { id: string; workspaceDirectory: string } } } + | undefined; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBe(worktree); + expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree); + expect(workspaces.get(worktree)).toBeTruthy(); +}); + +test("open_project_request does not unarchive an archived parent workspace for a new child directory", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + const projects = new Map>(); + const workspaces = new Map>(); + const home = "/Users/moboudra"; + const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea"; + const archivedAt = "2026-04-24T08:00:00.000Z"; + + projects.set( + home, + createPersistedProjectRecord({ + projectId: home, + rootPath: home, + kind: "non_git", + displayName: "moboudra", + createdAt: "2026-04-24T07:00:00.000Z", + updatedAt: archivedAt, + archivedAt, + }), + ); + workspaces.set( + home, + createPersistedWorkspaceRecord({ + workspaceId: home, + projectId: home, + cwd: home, + kind: "directory", + displayName: "moboudra", + createdAt: "2026-04-24T07:00:00.000Z", + updatedAt: archivedAt, + archivedAt, + }), + ); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + await session.handleMessage({ + type: "open_project_request", + cwd: worktree, + requestId: "req-open-worktree-under-archived-home", + }); + + const response = emitted.find((message) => message.type === "open_project_response") as + | { payload: { error: unknown; workspace?: { id: string; workspaceDirectory: string } } } + | undefined; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBe(worktree); + expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree); + expect(workspaces.get(home)?.archivedAt).toBe(archivedAt); + expect(projects.get(home)?.archivedAt).toBe(archivedAt); +}); + +test("open_project_request reclassifies an archived directory workspace when git metadata becomes available", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + const projects = new Map>(); + const workspaces = new Map>(); + const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; + const repoRoot = "/Users/moboudra/dev/paseo"; + const remoteProjectId = "remote:github.com/getpaseo/paseo"; + const archivedAt = "2026-04-24T09:48:36.168Z"; + + projects.set( + cwd, + createPersistedProjectRecord({ + projectId: cwd, + rootPath: cwd, + kind: "non_git", + displayName: "desktop-daemon-settings", + createdAt: "2026-04-24T09:46:43.146Z", + updatedAt: archivedAt, + archivedAt, + }), + ); + workspaces.set( + cwd, + createPersistedWorkspaceRecord({ + workspaceId: cwd, + projectId: cwd, + cwd, + kind: "directory", + displayName: "desktop-daemon-settings", + createdAt: "2026-04-24T09:46:43.146Z", + updatedAt: archivedAt, + archivedAt, + }), + ); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceGitService.getSnapshot = async () => + createWorkspaceRuntimeSnapshot(cwd, { + git: { + isGit: true, + repoRoot: cwd, + currentBranch: "feature/desktop-daemon-settings", + remoteUrl: "git@github.com:getpaseo/paseo.git", + isPaseoOwnedWorktree: false, + mainRepoRoot: repoRoot, + }, + }); + + await session.handleMessage({ + type: "open_project_request", + cwd, + requestId: "req-open-archived-directory-now-git", + }); + + const response = emitted.find((message) => message.type === "open_project_response") as + | { + payload: { + error: unknown; + workspace?: { + id: string; + projectId: string; + workspaceKind: string; + }; + }; + } + | undefined; + + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.projectId).toBe(remoteProjectId); + expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); + expect(projects.get(remoteProjectId)?.kind).toBe("git"); + expect(workspaces.get(cwd)?.projectId).toBe(remoteProjectId); + expect(workspaces.get(cwd)?.kind).toBe("worktree"); + expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings"); +}); + +test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + const projects = new Map>(); + const workspaces = new Map>(); + const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; + const repoRoot = "/Users/moboudra/dev/paseo"; + + projects.set( + cwd, + createPersistedProjectRecord({ + projectId: cwd, + rootPath: cwd, + kind: "non_git", + displayName: "desktop-daemon-settings", + createdAt: "2026-04-24T09:46:43.146Z", + updatedAt: "2026-04-24T09:46:43.146Z", + }), + ); + projects.set( + repoRoot, + createPersistedProjectRecord({ + projectId: repoRoot, + rootPath: repoRoot, + kind: "git", + displayName: "paseo", + createdAt: "2026-04-24T09:40:00.000Z", + updatedAt: "2026-04-24T09:40:00.000Z", + }), + ); + workspaces.set( + cwd, + createPersistedWorkspaceRecord({ + workspaceId: cwd, + projectId: cwd, + cwd, + kind: "directory", + displayName: "desktop-daemon-settings", + createdAt: "2026-04-24T09:46:43.146Z", + updatedAt: "2026-04-24T09:46:43.146Z", + }), + ); + workspaces.set( + repoRoot, + createPersistedWorkspaceRecord({ + workspaceId: repoRoot, + projectId: repoRoot, + cwd: repoRoot, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-04-24T09:40:00.000Z", + updatedAt: "2026-04-24T09:40:00.000Z", + }), + ); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceGitService.getSnapshot = async (requestedCwd: string) => + createWorkspaceRuntimeSnapshot(requestedCwd, { + git: { + isGit: true, + repoRoot: requestedCwd, + currentBranch: requestedCwd === repoRoot ? "main" : "feature/desktop-daemon-settings", + remoteUrl: "git@github.com:getpaseo/paseo.git", + isPaseoOwnedWorktree: false, + mainRepoRoot: requestedCwd === repoRoot ? null : repoRoot, + }, + }); + + await session.handleMessage({ + type: "open_project_request", + cwd, + requestId: "req-open-active-directory-now-git", + }); + + const response = emitted.find((message) => message.type === "open_project_response") as + | { + payload: { + error: unknown; + workspace?: { + id: string; + projectId: string; + workspaceKind: string; + }; + }; + } + | undefined; + + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.projectId).toBe(repoRoot); + expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); + expect(workspaces.get(cwd)?.projectId).toBe(repoRoot); + expect(workspaces.get(cwd)?.kind).toBe("worktree"); + expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings"); +}); + +test("open_project_request groups a plain git worktree under an existing repo project", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + const projects = new Map>(); + const workspaces = new Map>(); + const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; + const repoRoot = "/Users/moboudra/dev/paseo"; + + projects.set( + repoRoot, + createPersistedProjectRecord({ + projectId: repoRoot, + rootPath: repoRoot, + kind: "git", + displayName: "paseo", + createdAt: "2026-04-24T09:46:43.146Z", + updatedAt: "2026-04-24T09:46:43.146Z", + }), + ); + workspaces.set( + repoRoot, + createPersistedWorkspaceRecord({ + workspaceId: repoRoot, + projectId: repoRoot, + cwd: repoRoot, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-04-24T09:46:43.146Z", + updatedAt: "2026-04-24T09:46:43.146Z", + }), + ); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceGitService.getSnapshot = async (requestedCwd: string) => + createWorkspaceRuntimeSnapshot(requestedCwd, { + git: { + isGit: true, + repoRoot: requestedCwd, + currentBranch: requestedCwd === repoRoot ? "main" : "feature/desktop-daemon-settings", + remoteUrl: "git@github.com:getpaseo/paseo.git", + isPaseoOwnedWorktree: false, + mainRepoRoot: requestedCwd === repoRoot ? null : repoRoot, + }, + }); + + await session.handleMessage({ + type: "open_project_request", + cwd, + requestId: "req-open-plain-git-worktree", + }); + + const response = emitted.find((message) => message.type === "open_project_response") as + | { + payload: { + error: unknown; + workspace?: { + id: string; + projectId: string; + workspaceKind: string; + }; + }; + } + | undefined; + + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.projectId).toBe(repoRoot); + expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); + expect(workspaces.get(cwd)?.projectId).toBe(repoRoot); + expect(workspaces.get(cwd)?.kind).toBe("worktree"); +}); + test("open_project_request unarchives an existing archived workspace and project", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; const session = createSessionForWorkspaceTests(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index aec9bcd76..4a7a5b188 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -284,6 +284,37 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("getSnapshot keeps plain git classification when shortstat lookup fails", async () => { + const getCheckoutShortstat = vi.fn(async () => { + throw new Error( + "Missing Paseo worktree base metadata: /tmp/repo/.git/worktrees/feature/paseo/worktree.json", + ); + }); + const service = createService({ + getCheckoutStatus: vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { + repoRoot: cwd, + currentBranch: "feature/worktree", + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/main-repo", + }), + ), + getCheckoutShortstat, + }); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( + createSnapshot("/tmp/repo", { + git: { + repoRoot: "/tmp/repo", + currentBranch: "feature/worktree", + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/main-repo", + diffStat: null, + }, + }), + ); + }); + test("non-forced GitHub refresh does not emit when pull request state is unchanged", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 4aec01ce2..dd4ed960f 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1520,7 +1520,7 @@ async function loadWorkspaceGitRuntimeSnapshot( } const [diffStat, github] = await Promise.all([ - deps.getCheckoutShortstat(cwd, context, { force: options?.force }), + deps.getCheckoutShortstat(cwd, context, { force: options?.force }).catch(() => null), loadGitHubSnapshot({ cwd, remoteUrl: checkoutStatus.remoteUrl, @@ -1536,7 +1536,7 @@ async function loadWorkspaceGitRuntimeSnapshot( git: { isGit: true, repoRoot: checkoutStatus.repoRoot, - mainRepoRoot: checkoutStatus.isPaseoOwnedWorktree ? checkoutStatus.mainRepoRoot : null, + mainRepoRoot: checkoutStatus.mainRepoRoot, currentBranch: checkoutStatus.currentBranch, remoteUrl: checkoutStatus.remoteUrl, isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 4cb192c1f..4029c749d 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -5,12 +5,7 @@ import type { Logger } from "pino"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import { - buildProjectPlacementForCwd, - deriveWorkspaceId, - deriveProjectKind, - deriveProjectRootPath, - deriveWorkspaceDisplayName, - deriveWorkspaceKind, + classifyDirectoryForProjectMembership, normalizeWorkspaceId, } from "./workspace-registry-model.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; @@ -73,40 +68,38 @@ export async function bootstrapWorkspaceRegistries(options: { const recordsByWorkspaceId = new Map< string, { - placement: Awaited>; + membership: Awaited>; records: StoredAgentRecord[]; } >(); const placements = await Promise.all( activeRecords.map(async (record) => { const normalizedCwd = normalizeWorkspaceId(record.cwd); - const placement = await buildProjectPlacementForCwd({ + const membership = await classifyDirectoryForProjectMembership({ cwd: normalizedCwd, workspaceGitService: options.workspaceGitService, }); - const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); - return { record, placement, workspaceId }; + return { record, membership, workspaceId: membership.workspaceId }; }), ); - for (const { record, placement, workspaceId } of placements) { - const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] }; + for (const { record, membership, workspaceId } of placements) { + const existing = recordsByWorkspaceId.get(workspaceId) ?? { membership, records: [] }; existing.records.push(record); recordsByWorkspaceId.set(workspaceId, existing); } const projectRanges = new Map(); - type Placement = Awaited>; const workspaceUpsertInputs: { workspaceId: string; - placement: Placement; + membership: Awaited>; workspaceCwd: string; createdAt: string; updatedAt: string; }[] = []; for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) { - const { placement, records: workspaceRecords } = entry; - const workspaceCwd = placement.checkout.cwd; + const { membership, records: workspaceRecords } = entry; + const workspaceCwd = membership.checkout.cwd; let workspaceCreatedAt: string | null = null; let workspaceUpdatedAt: string | null = null; for (const record of workspaceRecords) { @@ -117,21 +110,21 @@ export async function bootstrapWorkspaceRegistries(options: { const createdAt = workspaceCreatedAt ?? new Date().toISOString(); const updatedAt = workspaceUpdatedAt ?? createdAt; - const existingProjectRange = projectRanges.get(placement.projectKey) ?? { + const existingProjectRange = projectRanges.get(membership.projectKey) ?? { createdAt: null, updatedAt: null, }; existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt); existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt); - projectRanges.set(placement.projectKey, existingProjectRange); + projectRanges.set(membership.projectKey, existingProjectRange); - workspaceUpsertInputs.push({ workspaceId, placement, workspaceCwd, createdAt, updatedAt }); + workspaceUpsertInputs.push({ workspaceId, membership, workspaceCwd, createdAt, updatedAt }); } await Promise.all( workspaceUpsertInputs.flatMap( - ({ workspaceId, placement, workspaceCwd, createdAt, updatedAt }) => { - const projectRange = projectRanges.get(placement.projectKey) ?? { + ({ workspaceId, membership, workspaceCwd, createdAt, updatedAt }) => { + const projectRange = projectRanges.get(membership.projectKey) ?? { createdAt: null, updatedAt: null, }; @@ -139,26 +132,20 @@ export async function bootstrapWorkspaceRegistries(options: { options.workspaceRegistry.upsert( createPersistedWorkspaceRecord({ workspaceId, - projectId: placement.projectKey, + projectId: membership.projectKey, cwd: workspaceCwd, - kind: deriveWorkspaceKind(placement.checkout), - displayName: deriveWorkspaceDisplayName({ - cwd: workspaceCwd, - checkout: placement.checkout, - }), + kind: membership.workspaceKind, + displayName: membership.workspaceDisplayName, createdAt, updatedAt, }), ), options.projectRegistry.upsert( createPersistedProjectRecord({ - projectId: placement.projectKey, - rootPath: deriveProjectRootPath({ - cwd: workspaceCwd, - checkout: placement.checkout, - }), - kind: deriveProjectKind(placement.checkout), - displayName: placement.projectName, + projectId: membership.projectKey, + rootPath: membership.projectRootPath, + kind: membership.projectKind, + displayName: membership.projectName, createdAt: projectRange.createdAt ?? createdAt, updatedAt: projectRange.updatedAt ?? updatedAt, }), diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index a05889b16..2f8536b1e 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test, vi } from "vitest"; import { + classifyDirectoryForProjectMembership, + deriveProjectRootPath, + deriveWorkspaceKind, deriveWorkspaceId, detectStaleWorkspaces, normalizeWorkspaceId, @@ -104,3 +107,78 @@ describe("deriveWorkspaceId", () => { ).toBe(normalizeWorkspaceId("/tmp/repo/scratch")); }); }); + +describe("git worktree grouping", () => { + test("classifies plain git worktrees for project membership from git facts", async () => { + const membership = await classifyDirectoryForProjectMembership({ + cwd: "/tmp/repo-feature", + workspaceGitService: { + getSnapshot: async () => ({ + cwd: "/tmp/repo-feature", + git: { + isGit: true, + repoRoot: "/tmp/repo-feature", + mainRepoRoot: "/tmp/repo", + currentBranch: "feature/plain", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: true, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + }, + }), + } as never, + }); + + expect(membership).toMatchObject({ + cwd: "/tmp/repo-feature", + workspaceId: "/tmp/repo-feature", + workspaceKind: "worktree", + workspaceDisplayName: "feature/plain", + projectKey: "remote:github.com/acme/repo", + projectName: "acme/repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + }); + }); + + test("uses mainRepoRoot as the project root for plain git worktrees", () => { + expect( + deriveProjectRootPath({ + cwd: "/tmp/repo-feature", + checkout: { + cwd: "/tmp/repo-feature", + isGit: true, + currentBranch: "feature/plain", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: "/tmp/repo-feature", + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/repo", + }, + }), + ).toBe("/tmp/repo"); + }); + + test("classifies plain git worktrees as workspaces of kind worktree", () => { + expect( + deriveWorkspaceKind({ + cwd: "/tmp/repo-feature", + isGit: true, + currentBranch: "feature/plain", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: "/tmp/repo-feature", + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/repo", + }), + ).toBe("worktree"); + }); +}); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 5cf281c99..25a51a23b 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -7,6 +7,19 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"; + +export interface DirectoryProjectMembership { + cwd: string; + checkout: ProjectCheckoutLitePayload; + workspaceId: string; + workspaceKind: PersistedWorkspaceKind; + workspaceDisplayName: string; + projectKey: string; + projectName: string; + projectRootPath: string; + projectKind: PersistedProjectKind; +} + export interface DetectStaleWorkspacesInput { activeWorkspaces: PersistedWorkspaceRecord[]; checkDirectoryExists: (cwd: string) => Promise; @@ -75,7 +88,6 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null { export function deriveProjectGroupingKey(options: { cwd: string; remoteUrl: string | null; - isPaseoOwnedWorktree: boolean; mainRepoRoot: string | null; }): string { const remoteKey = deriveRemoteProjectKey(options.remoteUrl); @@ -84,7 +96,7 @@ export function deriveProjectGroupingKey(options: { } const mainRepoRoot = options.mainRepoRoot?.trim(); - if (options.isPaseoOwnedWorktree && mainRepoRoot) { + if (mainRepoRoot) { return mainRepoRoot; } @@ -122,7 +134,7 @@ export function deriveProjectRootPath(input: { cwd: string; checkout: ProjectCheckoutLitePayload; }): string { - if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) { + if (input.checkout.isGit && input.checkout.mainRepoRoot) { return input.checkout.mainRepoRoot; } return input.cwd; @@ -136,7 +148,7 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi if (!checkout.isGit) { return "directory"; } - return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout"; + return checkout.mainRepoRoot ? "worktree" : "local_checkout"; } export function checkoutLiteFromGitSnapshot( @@ -179,7 +191,7 @@ export function checkoutLiteFromGitSnapshot( remoteUrl: git.remoteUrl, worktreeRoot: git.repoRoot ?? cwd, isPaseoOwnedWorktree: false, - mainRepoRoot: null, + mainRepoRoot: git.mainRepoRoot, }; } @@ -207,6 +219,18 @@ export async function buildProjectPlacementForCwd(input: { cwd: string; workspaceGitService: WorkspaceGitService; }): Promise { + const membership = await classifyDirectoryForProjectMembership(input); + return { + projectKey: membership.projectKey, + projectName: membership.projectName, + checkout: membership.checkout, + }; +} + +export async function classifyDirectoryForProjectMembership(input: { + cwd: string; + workspaceGitService: WorkspaceGitService; +}): Promise { const normalizedCwd = normalizeWorkspaceId(input.cwd); const checkout = await input.workspaceGitService .getSnapshot(normalizedCwd) @@ -229,13 +253,24 @@ export async function buildProjectPlacementForCwd(input: { const projectKey = deriveProjectGroupingKey({ cwd: checkout.worktreeRoot ?? normalizedCwd, remoteUrl: checkout.remoteUrl, - isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, mainRepoRoot: checkout.mainRepoRoot, }); return { + cwd: normalizedCwd, + checkout, + workspaceId: deriveWorkspaceId(normalizedCwd, checkout), + workspaceKind: deriveWorkspaceKind(checkout), + workspaceDisplayName: deriveWorkspaceDisplayName({ + cwd: normalizedCwd, + checkout, + }), projectKey, projectName: deriveProjectGroupingName(projectKey), - checkout, + projectRootPath: deriveProjectRootPath({ + cwd: normalizedCwd, + checkout, + }), + projectKind: deriveProjectKind(checkout), }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 5ad1c4d32..873bf5160 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1994,7 +1994,7 @@ export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z remoteUrl: z.string().nullable(), worktreeRoot: z.string().optional(), isPaseoOwnedWorktree: z.literal(false), - mainRepoRoot: z.null(), + mainRepoRoot: z.string().nullable(), }) .transform((value) => ({ ...value, @@ -2489,6 +2489,7 @@ const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ isGit: z.literal(true), isPaseoOwnedWorktree: z.literal(false), repoRoot: z.string(), + mainRepoRoot: z.string().nullable(), currentBranch: z.string().nullable(), isDirty: z.boolean(), baseRef: z.string().nullable(), diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 7d222fba1..db896c913 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -854,6 +854,18 @@ const x = 1; expect(status.mainRepoRoot).toBe(mainCheckoutDir); }); + it("detects plain git worktrees from git alone", async () => { + const worktreeDir = join(tempDir, "plain-git-worktree"); + execSync(`git worktree add -b feature/plain ${worktreeDir} main`, { cwd: repoDir }); + + const status = await getCheckoutStatus(worktreeDir, { paseoHome }); + expect(status.isGit).toBe(true); + expect(status.repoRoot).toBe(worktreeDir); + expect(status.isPaseoOwnedWorktree).toBe(false); + expect(status.mainRepoRoot).toBe(repoDir); + expect(status.currentBranch).toBe("feature/plain"); + }); + it("merges the current branch into base from a worktree checkout", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "main", @@ -1770,23 +1782,50 @@ const x = 1; ).toThrow(); }); - it("throws if Paseo worktree base metadata is missing", async () => { + it("falls back to the repository default branch for base-dependent operations when metadata is missing", async () => { const worktree = await createLegacyWorktreeForTest({ - branchName: "main", + branchName: "feature-default-base", cwd: repoDir, baseBranch: "main", worktreeSlug: "missing-metadata", paseoHome, }); + writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: worktree.worktreePath }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + cwd: worktree.worktreePath, + }); + const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); - await expect(getCheckoutStatus(worktree.worktreePath, { paseoHome })).rejects.toThrow(/base/i); - await expect( - getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }), - ).rejects.toThrow(/base/i); - await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i); + const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); + expect(baseDiff.diff).toContain("feature.txt"); + + const shortstat = await getCheckoutShortstat(worktree.worktreePath, { paseoHome }); + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("falls back to plain git checkout status when Paseo worktree metadata is missing", async () => { + const worktree = await createLegacyWorktreeForTest({ + branchName: "feature", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "missing-metadata-status-fallback", + paseoHome, + }); + + const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + rmSync(metadataPath, { force: true }); + + const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); + expect(status.isGit).toBe(true); + expect(status.currentBranch).toBe("feature"); + expect(status.repoRoot).toBe(worktree.worktreePath); + expect(status.isPaseoOwnedWorktree).toBe(true); + expect(status.mainRepoRoot).toBe(repoDir); + expect(status.baseRef).toBe("main"); }); describe("parseWorktreeList", () => { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 75cc1ae6d..0e983a8d2 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -17,7 +17,7 @@ import { import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand } from "./run-git-command.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; -import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; +import { readPaseoWorktreeMetadata } from "./worktree-metadata.js"; const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", @@ -643,6 +643,7 @@ export interface CheckoutStatus { export interface CheckoutStatusGitNonPaseo { isGit: true; repoRoot: string; + mainRepoRoot: string | null; currentBranch: string | null; isDirty: boolean; baseRef: string | null; @@ -878,28 +879,67 @@ export async function renameCurrentBranch( return { previousBranch, currentBranch }; } -type ConfiguredBaseRefForCwd = - | { baseRef: null; isPaseoOwnedWorktree: false } - | { baseRef: string; isPaseoOwnedWorktree: true }; +type PaseoWorktreeForCwd = + | { isPaseoOwnedWorktree: false } + | { isPaseoOwnedWorktree: true; worktreeRoot: string }; -async function getConfiguredBaseRefForCwd( +async function getPaseoWorktreeForCwd( cwd: string, context?: CheckoutContext, -): Promise { +): Promise { // Fast-path reject: non-worktree paths do not need expensive ownership checks. if (!/[\\/]worktrees[\\/]/.test(cwd)) { - return { baseRef: null, isPaseoOwnedWorktree: false }; + return { isPaseoOwnedWorktree: false }; } const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome }); if (!ownership.allowed) { - return { baseRef: null, isPaseoOwnedWorktree: false }; + return { isPaseoOwnedWorktree: false }; } - const worktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; return { - baseRef: requirePaseoWorktreeBaseRefName(worktreeRoot), isPaseoOwnedWorktree: true, + worktreeRoot: (await getWorktreeRoot(cwd)) ?? cwd, + }; +} + +function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null { + return readPaseoWorktreeMetadata(worktreeRoot)?.baseRefName ?? null; +} + +async function getStoredBaseRefForCwd( + cwd: string, + context?: CheckoutContext, +): Promise { + const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context); + if (!paseoWorktree.isPaseoOwnedWorktree) { + return null; + } + + return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot); +} + +async function getResolvedBaseRefForCwd( + cwd: string, + context?: CheckoutContext, +): Promise { + const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); + return resolvedBaseRef; +} + +interface BaseRefResolution { + storedBaseRef: string | null; + resolvedBaseRef: string | null; +} + +async function resolveBaseRefForCwd( + cwd: string, + context?: CheckoutContext, +): Promise { + const storedBaseRef = await getStoredBaseRefForCwd(cwd, context); + return { + storedBaseRef, + resolvedBaseRef: storedBaseRef ?? (await resolveBaseRef(cwd)), }; } @@ -1225,7 +1265,7 @@ interface CheckoutInspectionContext { worktreeRoot: string; currentBranch: string | null; remoteUrl: string | null; - configured: ConfiguredBaseRefForCwd; + paseoWorktree: PaseoWorktreeForCwd; } async function inspectCheckoutContext( @@ -1238,17 +1278,17 @@ async function inspectCheckoutContext( return null; } - const [currentBranch, remoteUrl, configured] = await Promise.all([ + const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([ getCurrentBranch(cwd), getOriginRemoteUrl(cwd), - getConfiguredBaseRefForCwd(cwd, context), + getPaseoWorktreeForCwd(cwd, context), ]); return { worktreeRoot: root, currentBranch, remoteUrl, - configured, + paseoWorktree, }; } catch (error) { if (isGitError(error)) { @@ -1380,25 +1420,25 @@ export async function getCheckoutStatus( const worktreeRoot = inspected.worktreeRoot; const currentBranch = inspected.currentBranch; const remoteUrl = inspected.remoteUrl; - const configured = inspected.configured; + const paseoWorktree = inspected.paseoWorktree; const isDirty = await isWorkingTreeDirty(cwd); const hasRemote = remoteUrl !== null; - const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); + const { resolvedBaseRef: baseRef } = await resolveBaseRefForCwd(cwd, context); + const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null); const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([ baseRef && currentBranch ? getAheadBehind(cwd, baseRef, currentBranch) : Promise.resolve(null), hasRemote && currentBranch ? getAheadOfOrigin(cwd, currentBranch) : Promise.resolve(null), hasRemote && currentBranch ? getBehindOfOrigin(cwd, currentBranch) : Promise.resolve(null), ]); - if (configured.isPaseoOwnedWorktree) { - const mainRepoRoot = await getMainRepoRoot(cwd); + if (paseoWorktree.isPaseoOwnedWorktree && baseRef) { return { isGit: true, repoRoot: worktreeRoot, - mainRepoRoot, + mainRepoRoot: mainRepoRoot ?? worktreeRoot, currentBranch, isDirty, - baseRef: configured.baseRef, + baseRef, aheadBehind, aheadOfOrigin, behindOfOrigin, @@ -1411,6 +1451,8 @@ export async function getCheckoutStatus( return { isGit: true, repoRoot: worktreeRoot, + mainRepoRoot: + mainRepoRoot && resolve(mainRepoRoot) !== resolve(worktreeRoot) ? mainRepoRoot : null, currentBranch, isDirty, baseRef, @@ -1462,8 +1504,7 @@ async function getCheckoutShortstatUncached( return null; } - const configured = await getConfiguredBaseRefForCwd(cwd, context); - const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); + const localBaseRef = await getResolvedBaseRefForCwd(cwd, context); const currentBranch = await getCurrentBranch(cwd); let comparisonRef: string; @@ -1739,12 +1780,12 @@ async function resolveCheckoutDiffRefs( if (compare.mode === "uncommitted") { return { baseRef: "HEAD", includeUntracked: true }; } - const configured = await getConfiguredBaseRefForCwd(cwd, context); - const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd)); + const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); + const baseRef = compare.baseRef ?? resolvedBaseRef; if (!baseRef) { return null; } - if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) { + if (storedBaseRef && compare.baseRef && compare.baseRef !== storedBaseRef) { throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`); } const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); @@ -1983,12 +2024,12 @@ export async function mergeToBase( ): Promise { await requireGitRepo(cwd); const currentBranch = await getCurrentBranch(cwd); - const configured = await getConfiguredBaseRefForCwd(cwd, context); - const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(cwd)); + const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); + const baseRef = options.baseRef ?? resolvedBaseRef; if (!baseRef) { throw new Error("Unable to determine base branch for merge"); } - if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) { + if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) { throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`); } if (!currentBranch) { @@ -2059,12 +2100,12 @@ export async function mergeFromBase( throw new Error("Unable to determine current branch for merge"); } - const configured = await getConfiguredBaseRefForCwd(cwd, context); - const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(cwd)); + const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); + const baseRef = options.baseRef ?? resolvedBaseRef; if (!baseRef) { throw new Error("Unable to determine base branch for merge"); } - if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) { + if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) { throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`); } @@ -2238,6 +2279,7 @@ export async function createPullRequest( options: CreatePullRequestOptions, github: GitHubService = createGitHubService(), workspaceGitService: GitHubRepoRemoteUrlResolver, + context?: CheckoutContext, ): Promise<{ url: string; number: number }> { await requireGitRepo(cwd); const repo = await resolveGitHubRepo(cwd, { workspaceGitService }); @@ -2246,8 +2288,8 @@ export async function createPullRequest( } const head = options.head ?? (await getCurrentBranch(cwd)); - const configured = await getConfiguredBaseRefForCwd(cwd); - const base = configured.baseRef ?? options.base ?? (await resolveBaseRef(cwd)); + const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); + const base = options.base ?? resolvedBaseRef; if (!head) { throw new Error("Unable to determine head branch for PR"); } @@ -2255,7 +2297,7 @@ export async function createPullRequest( throw new Error("Unable to determine base branch for PR"); } const normalizedBase = normalizeLocalBranchRefName(base); - if (configured.isPaseoOwnedWorktree && options.base && options.base !== base) { + if (storedBaseRef && options.base && options.base !== storedBaseRef) { throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`); }