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.
This commit is contained in:
Mohamed Boudra
2026-07-16 13:42:31 +00:00
parent 9540c75dca
commit 24ec299f7f
15 changed files with 369 additions and 41 deletions

View File

@@ -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({

View File

@@ -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";

View File

@@ -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" ? (
<Text style={styles.stateText} testID="add-project-flow-empty">
{emptyText(page)}
{emptyText(page, host ?? null)}
</Text>
) : null}
</ScrollView>

View File

@@ -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);

View File

@@ -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");

View File

@@ -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({

View File

@@ -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,

View File

@@ -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),

View File

@@ -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";

View File

@@ -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);

View File

@@ -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<PersistedWorkspaceRecord> {
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<ReturnType<WorkspaceGitService["getCheckout"]>>,
): Promise<void> {
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,

View File

@@ -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",
});
});
});

View File

@@ -23,7 +23,9 @@ export type ReconciliationChange =
kind: "workspace_updated";
workspaceId: string;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "branch" | "kind">>;
fields: Partial<
Pick<PersistedWorkspaceRecord, "branch" | "kind" | "isPaseoOwnedWorktree" | "mainRepoRoot">
>;
};
export interface ReconciliationResult {
@@ -234,7 +236,12 @@ export class WorkspaceReconciliationService {
workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => {
const expectedKind = deriveWorkspaceKind(wsGit);
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "branch" | "kind">> = {};
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;

View File

@@ -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 () => {

View File

@@ -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,
});