From 24ec299f7f38be806b29713d9f0ec3ea2ec03c67 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 13:42:31 +0000 Subject: [PATCH] fix(projects): refresh checkout metadata when reopening folders Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors. --- .../app/src/add-project-flow/model.test.ts | 8 ++ packages/app/src/add-project-flow/options.ts | 19 ++-- .../app/src/components/add-project-flow.tsx | 13 ++- packages/app/src/hooks/use-open-project.ts | 3 +- .../bootstrap-provider-availability.test.ts | 14 +-- .../src/server/paseo-worktree-service.test.ts | 2 +- .../src/server/paseo-worktree-service.ts | 2 + packages/server/src/server/session.ts | 11 +-- .../src/server/session.workspaces.test.ts | 60 +++++++++++ .../workspace-provisioning-service.test.ts | 99 +++++++++++++++++++ .../workspace-provisioning-service.ts | 72 +++++++++++++- .../workspace-reconciliation-service.test.ts | 62 ++++++++++++ .../workspace-reconciliation-service.ts | 19 +++- .../src/server/workspace-registry.test.ts | 13 ++- .../server/src/server/workspace-registry.ts | 13 ++- 15 files changed, 369 insertions(+), 41 deletions(-) diff --git a/packages/app/src/add-project-flow/model.test.ts b/packages/app/src/add-project-flow/model.test.ts index 8319771c3..ff496f53c 100644 --- a/packages/app/src/add-project-flow/model.test.ts +++ b/packages/app/src/add-project-flow/model.test.ts @@ -16,6 +16,7 @@ import { type AddProjectHost, } from "./model"; import { + addProjectMethodEmptyText, buildAddProjectMethods, buildCloneLocationOptions, buildManualGithubRepositoryChoices, @@ -110,6 +111,13 @@ describe("Add Project navigation", () => { }); describe("Add Project options", () => { + it("hides every mutating method when the host lacks stable project identity", () => { + const outdatedHost = { ...HOST, canAddProject: false }; + + expect(buildAddProjectMethods(outdatedHost)).toEqual([]); + expect(addProjectMethodEmptyText(outdatedHost)).toBe("Update the host to use Add Project."); + }); + it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => { expect( buildAddProjectMethods({ diff --git a/packages/app/src/add-project-flow/options.ts b/packages/app/src/add-project-flow/options.ts index 0c968cc35..1c9fec5bf 100644 --- a/packages/app/src/add-project-flow/options.ts +++ b/packages/app/src/add-project-flow/options.ts @@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A } export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] { + if (!host.canAddProject) return []; const options: AddProjectMethodOption[] = []; - if (host.canAddProject) { - options.push({ - id: "directory-search", - label: "Search for directory", - description: `Find a directory on ${host.label}`, - }); - } + options.push({ + id: "directory-search", + label: "Search for directory", + description: `Find a directory on ${host.label}`, + }); if (host.canBrowse) { options.push({ id: "browse", @@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp return options; } +export function addProjectMethodEmptyText(host: AddProjectHost | null): string { + return host?.canAddProject === false + ? "Update the host to use Add Project." + : "No matching options"; +} + function githubMethodDescription(host: AddProjectHost): string { if (!host.canCloneGithubRepositories) { return "Update this host to clone GitHub repositories"; diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 36aa29d97..cfdda3d86 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -45,6 +45,7 @@ import { } from "@/add-project-flow/model"; import { buildAddProjectMethods, + addProjectMethodEmptyText, buildCloneLocationOptions, buildManualGithubRepositoryChoices, buildSuggestedParentDirectories, @@ -161,9 +162,10 @@ function progressText(page: AddProjectPage): string { return "Adding project..."; } -function emptyText(page: AddProjectPage): string { +function emptyText(page: AddProjectPage, host: AddProjectHost | null): string { if (page.kind === "host") return "No connected hosts"; if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo"; + if (page.kind === "method") return addProjectMethodEmptyText(host); return "No matching options"; } @@ -297,6 +299,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd"); + // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. + const stableProjectIdentityByHost = useHostFeatureMap(hostIds, "stableProjectIdentity"); // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone"); // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. @@ -308,7 +312,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { () => hosts.flatMap((host) => { if (connectionStatuses.get(host.serverId) !== "online") return []; - const canAddProject = projectAddByHost.get(host.serverId) === true; + const canAddProject = + projectAddByHost.get(host.serverId) === true && + stableProjectIdentityByHost.get(host.serverId) === true; return [ { serverId: host.serverId, @@ -329,6 +335,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { hosts, localServerId, projectAddByHost, + stableProjectIdentityByHost, ], ); const [state, setState] = useState(() => @@ -893,7 +900,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { rows.length === 0 && page.kind !== "new-directory-name" ? ( - {emptyText(page)} + {emptyText(page, host ?? null)} ) : null} diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 29f571413..98bd07295 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -16,7 +16,8 @@ export function useOpenProject( const isConnected = useHostRuntimeIsConnected(normalizedServerId); const canAddProject = useSessionStore((state) => normalizedServerId - ? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true + ? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true && + state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true : false, ); const addEmptyProject = useSessionStore((state) => state.addEmptyProject); diff --git a/packages/server/src/server/bootstrap-provider-availability.test.ts b/packages/server/src/server/bootstrap-provider-availability.test.ts index 175379b3f..eb70da23f 100644 --- a/packages/server/src/server/bootstrap-provider-availability.test.ts +++ b/packages/server/src/server/bootstrap-provider-availability.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import pino from "pino"; @@ -27,22 +27,12 @@ describe("bootstrap provider availability", () => { const { createPaseoDaemon } = await import("./bootstrap.js"); const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-")); tempRoots.push(root); - const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-")); - tempRoots.push(binDir); const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], { encoding: "utf8", }) .split(/\r?\n/)[0] .trim(); - if (process.platform === "win32") { - await writeFile(path.join(binDir, "git.cmd"), `@"${gitPath}" %*\r\n`); - } else { - await symlink(gitPath, path.join(binDir, "git")); - } - process.env.PATH = binDir; - if (process.platform === "win32") { - process.env.PATHEXT = ".CMD"; - } + process.env.PATH = path.dirname(gitPath); expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i); const paseoHome = path.join(root, ".paseo"); const staticDir = path.join(root, "static"); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 79632f120..b77bfeb7f 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -749,7 +749,7 @@ function createDeps(options?: { get: async (projectId) => projects.get(projectId) ?? null, getOrCreateActiveByRoot: async (input) => { const existing = Array.from(projects.values()).find( - (project) => !project.archivedAt && project.rootPath === input.rootPath, + (project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath), ); if (existing) return existing; const project = createPersistedProjectRecordForTest({ diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 4ba94e3e5..fd9488718 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -242,6 +242,8 @@ async function upsertWorkspaceForWorktree(options: { displayName: options.worktree.branchName || normalizedCwd, branch: options.worktree.branchName || null, baseBranch: options.baseBranch ?? null, + isPaseoOwnedWorktree: true, + mainRepoRoot: normalizedRepoRoot, title: options.title ?? null, createdAt: now, updatedAt: now, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2f1729e92..d749556c7 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -261,14 +261,13 @@ function resolveSubscriptionId( function buildWorkspaceCheckout( workspace: PersistedWorkspaceRecord, - project: PersistedProjectRecord, // The persisted `branch` field is the source of truth, but it is null for // records created before branch was lifted to its own field (no migrations, // per data-model.md) and for any path that didn't backfill it. Fall back to // the live git branch so checkout.currentBranch never regresses to null. fallbackBranch?: string | null, ): ProjectPlacementPayload["checkout"] { - if (project.kind !== "git") { + if (workspace.kind === "directory") { return { cwd: workspace.cwd, isGit: false, @@ -280,7 +279,7 @@ function buildWorkspaceCheckout( }; } const currentBranch = workspace.branch ?? fallbackBranch ?? null; - if (workspace.kind === "worktree") { + if (workspace.isPaseoOwnedWorktree && workspace.mainRepoRoot) { return { cwd: workspace.cwd, isGit: true, @@ -288,7 +287,7 @@ function buildWorkspaceCheckout( remoteUrl: null, worktreeRoot: workspace.cwd, isPaseoOwnedWorktree: true, - mainRepoRoot: project.rootPath, + mainRepoRoot: workspace.mainRepoRoot, }; } return { @@ -298,7 +297,7 @@ function buildWorkspaceCheckout( remoteUrl: null, worktreeRoot: workspace.cwd, isPaseoOwnedWorktree: false, - mainRepoRoot: null, + mainRepoRoot: workspace.mainRepoRoot ?? null, }; } @@ -1368,7 +1367,7 @@ export class Session { } const liveBranch = this.workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null; - const checkout = buildWorkspaceCheckout(workspace, project, liveBranch); + const checkout = buildWorkspaceCheckout(workspace, liveBranch); return { projectKey: project.projectId, projectName: resolveProjectDisplayName(project), diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index c64893b96..ab08bb952 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -2112,6 +2112,66 @@ test("non-git workspace uses deterministic directory name and no unknown branch expect(result.entries[0]?.name).not.toBe("Unknown branch"); }); +test("workspace placements preserve checkout facts independently from the project", async () => { + const session = createSessionForWorkspaceTests(); + const manualWorktree = createPersistedWorkspaceRecord({ + workspaceId: "ws-manual-worktree", + projectId: "proj-manual-worktree", + cwd: "/tmp/manual-worktree", + kind: "worktree", + displayName: "manual", + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/main-repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const explicitDirectory = createPersistedWorkspaceRecord({ + workspaceId: "ws-explicit-directory", + projectId: "proj-manual-worktree", + cwd: "/tmp/plain-directory", + kind: "directory", + displayName: "plain", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: "proj-manual-worktree", + rootPath: "/tmp/main-repo", + kind: "git", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + session.workspaceRegistry.get = async (workspaceId: string) => + [manualWorktree, explicitDirectory].find( + (workspace) => workspace.workspaceId === workspaceId, + ) ?? null; + session.projectRegistry.get = async () => project; + + await expect( + session.buildProjectPlacementForWorkspaceId(manualWorktree.workspaceId), + ).resolves.toEqual( + expect.objectContaining({ + checkout: expect.objectContaining({ + isGit: true, + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/main-repo", + }), + }), + ); + await expect( + session.buildProjectPlacementForWorkspaceId(explicitDirectory.workspaceId), + ).resolves.toEqual( + expect.objectContaining({ + checkout: expect.objectContaining({ + isGit: false, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + }), + ); +}); + test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => { const session = createSessionForWorkspaceTests(); const archivedAt = "2026-03-02T12:00:00.000Z"; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 5ef19f77f..af1787560 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -9,6 +9,7 @@ import { createNoopWorkspaceGitService } from "../../test-utils/workspace-git-se import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry, + createPersistedWorkspaceRecord, type WorkspaceRegistry, } from "../../workspace-registry.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; @@ -116,6 +117,54 @@ test("re-opening an active workspace by exact path returns the same record witho expect(await workspaceRegistry.list()).toHaveLength(1); }); +test("re-opening an active workspace refreshes its checkout metadata", async () => { + const repo = path.join(tmpDir, "repo"); + const first = await provisioning.findOrCreateWorkspaceForDirectory(repo); + gitRoots.add(repo); + gitBranches.set(repo, "feature/refresh"); + + const refreshed = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(refreshed).toMatchObject({ + workspaceId: first.workspaceId, + kind: "local_checkout", + branch: "feature/refresh", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }); + expect(await workspaceRegistry.get(first.workspaceId)).toEqual(refreshed); + expect((await projectRegistry.get(first.projectId))?.kind).toBe("git"); +}); + +test("persists manual worktree ownership separately from its workspace kind", async () => { + const cwd = path.join(tmpDir, "manual-worktree"); + const mainRepoRoot = path.join(tmpDir, "main-repo"); + const manualWorktreeProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async () => ({ + cwd, + isGit: true, + currentBranch: "feature/manual", + remoteUrl: null, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot, + }), + }), + }); + + const workspace = await manualWorktreeProvisioning.findOrCreateWorkspaceForDirectory(cwd); + + expect(workspace).toMatchObject({ + kind: "worktree", + isPaseoOwnedWorktree: false, + mainRepoRoot, + }); +}); + test("re-opening an archived workspace by its exact path unarchives it and keeps the id", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); @@ -128,6 +177,56 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps expect(reopened.archivedAt).toBeNull(); }); +test("reopening archived exact-root records restores the fresh Git project", async () => { + const cwd = path.join(tmpDir, "repo"); + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: cwd, + kind: "non_git", + displayName: "repo", + timestamp: ARCHIVED_AT, + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived-root", + projectId: project.projectId, + cwd, + kind: "directory", + displayName: "repo", + createdAt: ARCHIVED_AT, + updatedAt: ARCHIVED_AT, + archivedAt: ARCHIVED_AT, + }); + await workspaceRegistry.upsert(workspace); + await projectRegistry.archive(project.projectId, ARCHIVED_AT); + const archivedProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async () => ({ + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: null, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + }), + }); + + const reopened = await archivedProvisioning.ensureWorkspaceRecordUnarchived(workspace); + + expect(reopened).toMatchObject({ + workspaceId: workspace.workspaceId, + kind: "local_checkout", + archivedAt: null, + }); + expect(await projectRegistry.get(project.projectId)).toMatchObject({ + kind: "git", + archivedAt: null, + }); +}); + test("uses one workspace snapshot when reopening an archived workspace", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 3a109d380..243c2bfba 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -1,4 +1,5 @@ import { basename, resolve } from "node:path"; +import { areEquivalentPaths } from "../../../utils/path.js"; import { deriveWorkspaceDisplayName, @@ -100,6 +101,8 @@ export function createWorkspaceProvisioningService(deps: { checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD" ? checkout.currentBranch : null, + isPaseoOwnedWorktree: checkout.isGit && checkout.isPaseoOwnedWorktree, + mainRepoRoot: checkout.isGit ? checkout.mainRepoRoot : null, title: title?.trim() || null, createdAt: timestamp, updatedAt: timestamp, @@ -118,7 +121,7 @@ export function createWorkspaceProvisioningService(deps: { Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.workspaceId.localeCompare(right.workspaceId), )[0]; - if (active) return active; + if (active) return refreshWorkspaceRecord(active); const archived = workspaces .filter((workspace) => workspace.archivedAt && workspace.cwd === normalizedCwd) .sort( @@ -147,9 +150,12 @@ export function createWorkspaceProvisioningService(deps: { const project = await projectRegistry.get(workspace.projectId); if (!project) throw new Error(`Unknown project: ${workspace.projectId}`); const timestamp = new Date().toISOString(); + const checkout = + workspace.archivedAt || project.archivedAt + ? await workspaceGitService.getCheckout(workspace.cwd) + : null; let next: PersistedWorkspaceRecord | null = null; - if (workspace.archivedAt) { - const checkout = await workspaceGitService.getCheckout(workspace.cwd); + if (workspace.archivedAt && checkout) { next = { ...workspace, kind: deriveWorkspaceKind(checkout), @@ -157,18 +163,74 @@ export function createWorkspaceProvisioningService(deps: { checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD" ? checkout.currentBranch : null, + isPaseoOwnedWorktree: checkout.isGit && checkout.isPaseoOwnedWorktree, + mainRepoRoot: checkout.isGit ? checkout.mainRepoRoot : null, archivedAt: null, updatedAt: timestamp, }; } - if (project.archivedAt) { - await projectRegistry.upsert({ ...project, archivedAt: null, updatedAt: timestamp }); + if (checkout && (project.archivedAt || workspace.archivedAt)) { + const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd) + ? checkout + : await workspaceGitService.getCheckout(project.rootPath); + const kind = projectCheckout.isGit ? "git" : "non_git"; + if (project.archivedAt || project.kind !== kind) { + await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp }); + } } if (!next) return workspace; await workspaceRegistry.upsert(next); return next; } + async function refreshWorkspaceRecord( + workspace: PersistedWorkspaceRecord, + ): Promise { + const checkout = await workspaceGitService.getCheckout(workspace.cwd); + const project = await projectRegistry.get(workspace.projectId); + if (project && !project.archivedAt) { + await refreshProjectKind(project, workspace.cwd, checkout); + } + const kind = deriveWorkspaceKind(checkout); + const branch = + checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD" + ? checkout.currentBranch + : null; + const isPaseoOwnedWorktree = checkout.isGit && checkout.isPaseoOwnedWorktree; + const mainRepoRoot = checkout.isGit ? checkout.mainRepoRoot : null; + if ( + workspace.kind === kind && + workspace.branch === branch && + workspace.isPaseoOwnedWorktree === isPaseoOwnedWorktree && + workspace.mainRepoRoot === mainRepoRoot + ) { + return workspace; + } + const next = { + ...workspace, + kind, + branch, + isPaseoOwnedWorktree, + mainRepoRoot, + updatedAt: new Date().toISOString(), + }; + await workspaceRegistry.upsert(next); + return next; + } + + async function refreshProjectKind( + project: PersistedProjectRecord, + workspaceCwd: string, + workspaceCheckout: Awaited>, + ): Promise { + const projectCheckout = areEquivalentPaths(project.rootPath, workspaceCwd) + ? workspaceCheckout + : await workspaceGitService.getCheckout(project.rootPath); + const kind = projectCheckout.isGit ? "git" : "non_git"; + if (project.kind === kind) return; + await projectRegistry.upsert({ ...project, kind, updatedAt: new Date().toISOString() }); + } + return { findOrCreateWorkspaceForDirectory, resolveOrCreateWorkspaceIdForCreateAgent, diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index b16aafefc..34874ab46 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -560,6 +560,8 @@ describe("WorkspaceReconciliationService", () => { pinnedAt: null, branch: null, baseBranch: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, createdAt: timestamp, updatedAt: expect.any(String), archivedAt: expect.any(String), @@ -1160,4 +1162,64 @@ describe("WorkspaceReconciliationService", () => { expect(infoRecords).toEqual([]); }); + + test("backfills persisted worktree ownership from the current checkout", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-worktree-owner-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const checkouts = new TestCheckouts(); + checkouts.set( + rootPath, + createCheckout(rootPath, { + isGit: true, + worktreeRoot: rootPath, + isPaseoOwnedWorktree: true, + mainRepoRoot: "/tmp/main-repo", + }), + ); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + kind: "git", + displayName: "worktree", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: rootPath, + kind: "worktree", + displayName: "worktree", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + workspaceGitService: checkouts, + logger: createTestLogger(), + }); + + const result = await service.reconcileGitMetadata(); + + expect(result.changesApplied).toEqual([ + { + kind: "workspace_updated", + workspaceId: "w1", + directory: rootPath, + fields: { isPaseoOwnedWorktree: true, mainRepoRoot: "/tmp/main-repo" }, + }, + ]); + expect(workspaces.get("w1")).toMatchObject({ + isPaseoOwnedWorktree: true, + mainRepoRoot: "/tmp/main-repo", + }); + }); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 0c2c92f83..669e94698 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -23,7 +23,9 @@ export type ReconciliationChange = kind: "workspace_updated"; workspaceId: string; directory: string; - fields: Partial>; + fields: Partial< + Pick + >; }; export interface ReconciliationResult { @@ -234,7 +236,12 @@ export class WorkspaceReconciliationService { workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => { const expectedKind = deriveWorkspaceKind(wsGit); - const workspaceUpdates: Partial> = {}; + const workspaceUpdates: Partial< + Pick< + PersistedWorkspaceRecord, + "branch" | "kind" | "isPaseoOwnedWorktree" | "mainRepoRoot" + > + > = {}; if (workspace.branch !== (wsGit.isGit ? wsGit.currentBranch : null)) { workspaceUpdates.branch = wsGit.isGit ? wsGit.currentBranch : null; @@ -243,6 +250,14 @@ export class WorkspaceReconciliationService { if (workspace.kind !== expectedKind) { workspaceUpdates.kind = expectedKind; } + const isPaseoOwnedWorktree = wsGit.isGit && wsGit.isPaseoOwnedWorktree; + const mainRepoRoot = wsGit.isGit ? wsGit.mainRepoRoot : null; + if (workspace.isPaseoOwnedWorktree !== isPaseoOwnedWorktree) { + workspaceUpdates.isPaseoOwnedWorktree = isPaseoOwnedWorktree; + } + if (workspace.mainRepoRoot !== mainRepoRoot) { + workspaceUpdates.mainRepoRoot = mainRepoRoot; + } if (Object.keys(workspaceUpdates).length === 0) { return; diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 95f1ad660..7c5f13ef2 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -210,7 +210,7 @@ describe("workspace registries", () => { expect(await projectRegistry.get(archived.projectId)).toEqual(archived); }); - test("returns the oldest active legacy duplicate without rewriting either record", async () => { + test("refreshes the oldest active legacy duplicate kind without rewriting its identity", async () => { await projectRegistry.initialize(); const rootPath = path.join(tmpDir, "legacy-root"); const oldest = createPersistedProjectRecord({ @@ -239,8 +239,15 @@ describe("workspace registries", () => { displayName: "new-name", timestamp: "2026-03-03T00:00:00.000Z", }), - ).resolves.toEqual(oldest); - expect(await projectRegistry.list()).toEqual([oldest, duplicate]); + ).resolves.toEqual({ + ...oldest, + kind: "non_git", + updatedAt: "2026-03-03T00:00:00.000Z", + }); + expect(await projectRegistry.list()).toEqual([ + { ...oldest, kind: "non_git", updatedAt: "2026-03-03T00:00:00.000Z" }, + duplicate, + ]); }); test("reuses an active project for Windows lexical-equivalent root spellings", async () => { diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 3bc101a06..78a8b0c69 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -58,6 +58,8 @@ const PersistedWorkspaceRecordSchema = z.object({ .nullable() .optional() .transform((value) => value ?? null), + isPaseoOwnedWorktree: z.boolean().default(false), + mainRepoRoot: z.string().nullable().default(null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -298,7 +300,12 @@ export class FileBackedProjectRegistry Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.projectId.localeCompare(right.projectId), )[0]; - if (active) return active; + if (active) { + if (active.kind === input.kind) return active; + const refreshed = { ...active, kind: input.kind, updatedAt: input.timestamp }; + await this.upsert(refreshed); + return refreshed; + } for (;;) { const projectId = this.projectIdFactory(); @@ -401,6 +408,8 @@ export function createPersistedWorkspaceRecord(input: { title?: string | null; branch?: string | null; baseBranch?: string | null; + isPaseoOwnedWorktree?: boolean; + mainRepoRoot?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -411,6 +420,8 @@ export function createPersistedWorkspaceRecord(input: { title: input.title ?? null, branch: input.branch ?? null, baseBranch: input.baseBranch ?? null, + isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false, + mainRepoRoot: input.mainRepoRoot ?? null, archivedAt: input.archivedAt ?? null, pinnedAt: input.pinnedAt ?? null, });