mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix duplicate project worktree registration (#1024)
* Fix duplicate project worktree registration * Update archive redirect test for project-aware routes
This commit is contained in:
@@ -362,6 +362,11 @@ Array of project records.
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
|
||||
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1173,10 +1173,13 @@ function ProjectHeaderRow({
|
||||
return;
|
||||
}
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href,
|
||||
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
}) as Href,
|
||||
);
|
||||
onWorkspacePress?.();
|
||||
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
|
||||
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const _toast = useToast();
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ function resolveCheckoutRequest(
|
||||
interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
sourceDirectory: string;
|
||||
projectId?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@@ -496,6 +497,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
export function NewWorkspaceScreen({
|
||||
serverId,
|
||||
sourceDirectory,
|
||||
projectId,
|
||||
displayName: displayNameProp,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -711,6 +713,7 @@ export function NewWorkspaceScreen({
|
||||
|
||||
return {
|
||||
cwd: input.cwd,
|
||||
...(projectId ? { projectId } : {}),
|
||||
worktreeSlug: createNameId(),
|
||||
...(hasFirstAgentContext
|
||||
? {
|
||||
@@ -723,7 +726,7 @@ export function NewWorkspaceScreen({
|
||||
...checkoutRequest,
|
||||
};
|
||||
},
|
||||
[currentBranch, selectedItem],
|
||||
[currentBranch, projectId, selectedItem],
|
||||
);
|
||||
|
||||
const ensureWorkspace = useCallback(
|
||||
|
||||
@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
|
||||
export function buildHostNewWorkspaceRoute(
|
||||
serverId: string,
|
||||
sourceDirectory: string,
|
||||
options?: { displayName?: string },
|
||||
options?: { displayName?: string; projectId?: string },
|
||||
) {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
|
||||
if (options?.displayName) {
|
||||
params.set("name", options.displayName);
|
||||
}
|
||||
if (options?.projectId) {
|
||||
params.set("projectId", options.projectId);
|
||||
}
|
||||
return `${base}/new?${params.toString()}` as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
|
||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||
workspaces,
|
||||
}),
|
||||
).toBe("/h/server-1/new?dir=%2Frepo&name=Project");
|
||||
).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1");
|
||||
});
|
||||
|
||||
it("redirects to the new workspace route when no sibling workspace target exists", () => {
|
||||
@@ -65,7 +65,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
|
||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||
workspaces,
|
||||
}),
|
||||
).toBe("/h/server-1/new?dir=%2Frepo&name=Project");
|
||||
).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1");
|
||||
});
|
||||
|
||||
it("redirects to the new workspace route instead of another workspace", () => {
|
||||
@@ -85,7 +85,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
|
||||
archivedWorkspaceId: "/notes",
|
||||
workspaces,
|
||||
}),
|
||||
).toBe("/h/server-1/new?dir=%2Fnotes&name=Project");
|
||||
).toBe("/h/server-1/new?dir=%2Fnotes&name=Project&projectId=notes");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +132,8 @@ describe("redirectIfArchivingActiveWorkspace", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledWith("/h/server-1/new?dir=%2Frepo&name=Project");
|
||||
expect(replaceMock).toHaveBeenCalledWith(
|
||||
"/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,5 +24,6 @@ export function buildWorkspaceArchiveRedirectRoute(input: {
|
||||
|
||||
return buildHostNewWorkspaceRoute(input.serverId, sourceDirectory, {
|
||||
displayName: archivedWorkspace.projectDisplayName,
|
||||
projectId: archivedWorkspace.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -872,6 +872,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
|
||||
const createPromise = client.createPaseoWorktree(
|
||||
{
|
||||
cwd: "/tmp/project",
|
||||
projectId: "remote:github.com/acme/project",
|
||||
worktreeSlug: "review-pr-123",
|
||||
refName: "feature/worktree-base-ref",
|
||||
action: "checkout",
|
||||
@@ -885,6 +886,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
|
||||
expect(request).toEqual({
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: "/tmp/project",
|
||||
projectId: "remote:github.com/acme/project",
|
||||
worktreeSlug: "review-pr-123",
|
||||
refName: "feature/worktree-base-ref",
|
||||
action: "checkout",
|
||||
|
||||
@@ -259,7 +259,13 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends Pick<
|
||||
CreatePaseoWorktreeRequest,
|
||||
"cwd" | "worktreeSlug" | "firstAgentContext" | "refName" | "action" | "githubPrNumber"
|
||||
| "cwd"
|
||||
| "projectId"
|
||||
| "worktreeSlug"
|
||||
| "firstAgentContext"
|
||||
| "refName"
|
||||
| "action"
|
||||
| "githubPrNumber"
|
||||
> {}
|
||||
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
@@ -3012,6 +3018,7 @@ export class DaemonClient {
|
||||
message: {
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: input.cwd,
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
worktreeSlug: input.worktreeSlug,
|
||||
...(input.firstAgentContext !== undefined
|
||||
? { firstAgentContext: input.firstAgentContext }
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
shutdownProviders,
|
||||
} from "./agent/provider-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
@@ -531,6 +532,26 @@ export async function createPaseoDaemon(
|
||||
logger,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
||||
const workspaceReconciliation = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
workspaceGitService,
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await workspaceReconciliation.runOnce();
|
||||
logger.info(
|
||||
{
|
||||
elapsed: elapsed(),
|
||||
changeCount: result.changesApplied.length,
|
||||
},
|
||||
"Workspace registries reconciled",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Background workspace reconciliation failed");
|
||||
}
|
||||
})();
|
||||
await chatService.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||
const checkoutDiffManager = new CheckoutDiffManager({
|
||||
|
||||
@@ -66,6 +66,40 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
]);
|
||||
});
|
||||
|
||||
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const sourceProject = createPersistedProjectRecordForTest({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: repoDir,
|
||||
displayName: "acme/repo",
|
||||
});
|
||||
const existingWorktree = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: path.join(tempDir, "existing-worktree"),
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: path.join(tempDir, "existing-worktree"),
|
||||
kind: "worktree",
|
||||
displayName: "existing-worktree",
|
||||
});
|
||||
deps.projects.set(sourceProject.projectId, sourceProject);
|
||||
deps.workspaces.set(existingWorktree.workspaceId, existingWorktree);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
projectId: sourceProject.projectId,
|
||||
worktreeSlug: "second-worktree",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
|
||||
expect(Array.from(deps.projects.keys()).sort()).toEqual(["remote:github.com/acme/repo"]);
|
||||
});
|
||||
|
||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"reuses an existing worktree and still upserts the workspace",
|
||||
|
||||
@@ -23,7 +23,9 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import type { FirstAgentContext } from "../shared/messages.js";
|
||||
|
||||
export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput;
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
@@ -60,6 +62,7 @@ export async function createPaseoWorktree(
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree: createdWorktree.worktree,
|
||||
deps,
|
||||
@@ -189,6 +192,7 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
@@ -202,6 +206,7 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
);
|
||||
const sourceProject = await resolveSourceProjectForWorktree({
|
||||
inputCwd: normalizedInputCwd,
|
||||
projectId: options.projectId,
|
||||
repoRoot: normalizedRepoRoot,
|
||||
existingWorkspace,
|
||||
deps: options.deps,
|
||||
@@ -215,6 +220,7 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
rootPath: sourceProject.rootPath,
|
||||
kind: sourceProject.kind,
|
||||
displayName: sourceProject.displayName,
|
||||
customName: sourceProject.customName,
|
||||
createdAt: sourceProject.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
@@ -236,18 +242,88 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
||||
}
|
||||
|
||||
async function resolveSourceProjectForWorktree(options: {
|
||||
inputCwd: string;
|
||||
repoRoot: string;
|
||||
existingWorkspace: PersistedWorkspaceRecord | null;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
}): Promise<{
|
||||
interface SourceProjectForWorktree {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
kind: "git";
|
||||
displayName: string;
|
||||
customName: string | null;
|
||||
createdAt: string | null;
|
||||
}> {
|
||||
}
|
||||
|
||||
function sourceProjectFromRecord(record: {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
displayName: string;
|
||||
customName?: string | null;
|
||||
createdAt?: string | null;
|
||||
}): SourceProjectForWorktree {
|
||||
return {
|
||||
projectId: record.projectId,
|
||||
rootPath: record.rootPath,
|
||||
kind: "git",
|
||||
displayName: record.displayName,
|
||||
customName: record.customName ?? null,
|
||||
createdAt: record.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveExplicitProjectForWorktree(options: {
|
||||
projectId: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const project = await options.projectRegistry.get(options.projectId);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found for worktree: ${options.projectId}`);
|
||||
}
|
||||
return sourceProjectFromRecord(project);
|
||||
}
|
||||
|
||||
async function resolveWorkspaceProjectForWorktree(options: {
|
||||
sourceWorkspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const sourceProject = await options.projectRegistry.get(options.sourceWorkspace.projectId);
|
||||
return sourceProjectFromRecord({
|
||||
projectId: options.sourceWorkspace.projectId,
|
||||
rootPath: sourceProject?.rootPath ?? options.repoRoot,
|
||||
displayName:
|
||||
sourceProject?.displayName ?? deriveProjectGroupingName(options.sourceWorkspace.projectId),
|
||||
customName: sourceProject?.customName ?? null,
|
||||
createdAt: sourceProject?.createdAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFallbackProjectForWorktree(options: {
|
||||
repoRoot: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const existingFallbackProject = await options.projectRegistry.get(options.repoRoot);
|
||||
return sourceProjectFromRecord({
|
||||
projectId: options.repoRoot,
|
||||
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
|
||||
displayName:
|
||||
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
|
||||
customName: existingFallbackProject?.customName ?? null,
|
||||
createdAt: existingFallbackProject?.createdAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSourceProjectForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
existingWorkspace: PersistedWorkspaceRecord | null;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
if (options.projectId) {
|
||||
return resolveExplicitProjectForWorktree({
|
||||
projectId: options.projectId,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceWorkspace =
|
||||
options.existingWorkspace ??
|
||||
(await findWorkspaceForSource({
|
||||
@@ -255,30 +331,19 @@ async function resolveSourceProjectForWorktree(options: {
|
||||
repoRoot: options.repoRoot,
|
||||
workspaceRegistry: options.deps.workspaceRegistry,
|
||||
}));
|
||||
const sourceProject = sourceWorkspace
|
||||
? await options.deps.projectRegistry.get(sourceWorkspace.projectId)
|
||||
: null;
|
||||
|
||||
if (sourceWorkspace) {
|
||||
return {
|
||||
projectId: sourceWorkspace.projectId,
|
||||
rootPath: sourceProject?.rootPath ?? options.repoRoot,
|
||||
kind: "git",
|
||||
displayName:
|
||||
sourceProject?.displayName ?? deriveProjectGroupingName(sourceWorkspace.projectId),
|
||||
createdAt: sourceProject?.createdAt ?? null,
|
||||
};
|
||||
return resolveWorkspaceProjectForWorktree({
|
||||
sourceWorkspace,
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
const existingFallbackProject = await options.deps.projectRegistry.get(options.repoRoot);
|
||||
return {
|
||||
projectId: options.repoRoot,
|
||||
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
|
||||
kind: "git",
|
||||
displayName:
|
||||
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
|
||||
createdAt: existingFallbackProject?.createdAt ?? null,
|
||||
};
|
||||
return resolveFallbackProjectForWorktree({
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
async function findWorkspaceForSource(options: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
@@ -336,6 +336,117 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect(workspaces.get("w1")!.kind).toBe("local_checkout");
|
||||
});
|
||||
|
||||
test("moves workspaces from a path-keyed duplicate project to the existing remote-keyed project", async () => {
|
||||
const repoDir = createTempGitRepo("reconcile-duplicate-project-");
|
||||
tempDirs.push(repoDir);
|
||||
const canonicalWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "focused-bat");
|
||||
const duplicateWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "gigantic-blowfish");
|
||||
mkdirSync(canonicalWorktreeDir, { recursive: true });
|
||||
mkdirSync(duplicateWorktreeDir, { recursive: true });
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"remote:github.com/blank-dot-page/editor",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
displayName: "blank-dot-page/editor",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
projects.set(
|
||||
repoDir,
|
||||
createPersistedProjectRecord({
|
||||
projectId: repoDir,
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
displayName: "editor",
|
||||
customName: "Editor",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"focused-bat",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "focused-bat",
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
cwd: canonicalWorktreeDir,
|
||||
kind: "worktree",
|
||||
displayName: "update-og-image",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"gigantic-blowfish",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "gigantic-blowfish",
|
||||
projectId: repoDir,
|
||||
cwd: duplicateWorktreeDir,
|
||||
kind: "worktree",
|
||||
displayName: "markdown-view",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: createWorkspaceGitServiceStub({
|
||||
[repoDir]: {
|
||||
projectKind: "git",
|
||||
projectDisplayName: "blank-dot-page/editor",
|
||||
workspaceDisplayName: "main",
|
||||
gitRemote: "git@github.com:blank-dot-page/editor.git",
|
||||
},
|
||||
[canonicalWorktreeDir]: {
|
||||
projectKind: "git",
|
||||
projectDisplayName: "blank-dot-page/editor",
|
||||
workspaceDisplayName: "update-og-image",
|
||||
gitRemote: "git@github.com:blank-dot-page/editor.git",
|
||||
},
|
||||
[duplicateWorktreeDir]: {
|
||||
projectKind: "git",
|
||||
projectDisplayName: "blank-dot-page/editor",
|
||||
workspaceDisplayName: "markdown-view",
|
||||
gitRemote: "git@github.com:blank-dot-page/editor.git",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: "gigantic-blowfish",
|
||||
fields: { projectId: "remote:github.com/blank-dot-page/editor" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "project_updated",
|
||||
projectId: "remote:github.com/blank-dot-page/editor",
|
||||
fields: { customName: "Editor" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "project_archived",
|
||||
projectId: repoDir,
|
||||
reason: "no_active_workspaces",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(workspaces.get("gigantic-blowfish")!.projectId).toBe(
|
||||
"remote:github.com/blank-dot-page/editor",
|
||||
);
|
||||
expect(projects.get("remote:github.com/blank-dot-page/editor")!.customName).toBe("Editor");
|
||||
expect(projects.get(repoDir)!.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("updates project display name when git remote changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-remote-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
|
||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||
|
||||
@@ -19,6 +20,21 @@ function deriveWorkspaceKindFromMetadata(metadata: {
|
||||
return "local_checkout";
|
||||
}
|
||||
|
||||
function chooseCanonicalProject(projects: PersistedProjectRecord[]): PersistedProjectRecord {
|
||||
return [...projects].sort((left, right) => {
|
||||
const leftRemote = left.projectId.startsWith("remote:");
|
||||
const rightRemote = right.projectId.startsWith("remote:");
|
||||
if (leftRemote !== rightRemote) {
|
||||
return leftRemote ? -1 : 1;
|
||||
}
|
||||
const createdAt = Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
||||
if (createdAt !== 0) {
|
||||
return createdAt;
|
||||
}
|
||||
return left.projectId.localeCompare(right.projectId);
|
||||
})[0]!;
|
||||
}
|
||||
|
||||
export type ReconciliationChange =
|
||||
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
||||
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
|
||||
@@ -26,13 +42,15 @@ export type ReconciliationChange =
|
||||
kind: "project_updated";
|
||||
projectId: string;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">>;
|
||||
fields: Partial<
|
||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath" | "customName">
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_updated";
|
||||
workspaceId: string;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">>;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "displayName" | "kind">>;
|
||||
};
|
||||
|
||||
export interface ReconciliationResult {
|
||||
@@ -144,7 +162,10 @@ export class WorkspaceReconciliationService {
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. Archive orphaned projects (all workspaces archived/removed)
|
||||
// 2. Merge duplicate active project records that point at the same repo root.
|
||||
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
|
||||
|
||||
// 3. Archive orphaned projects (all workspaces archived/removed)
|
||||
const orphanedProjects = activeProjects.filter((project) => {
|
||||
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||
return siblings.length === 0;
|
||||
@@ -162,7 +183,7 @@ export class WorkspaceReconciliationService {
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Reconcile git metadata for active projects whose directories still exist
|
||||
// 4. Reconcile git metadata for active projects whose directories still exist
|
||||
const projectsToReconcile = activeProjects.filter((project) => {
|
||||
if (project.archivedAt) return false;
|
||||
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||
@@ -183,6 +204,91 @@ export class WorkspaceReconciliationService {
|
||||
return { changesApplied: changes, durationMs: Date.now() - start };
|
||||
}
|
||||
|
||||
private async mergeDuplicateProjectsByRoot(
|
||||
activeProjects: PersistedProjectRecord[],
|
||||
workspacesByProject: Map<string, PersistedWorkspaceRecord[]>,
|
||||
changes: ReconciliationChange[],
|
||||
): Promise<void> {
|
||||
const projectsByRoot = new Map<string, PersistedProjectRecord[]>();
|
||||
for (const project of activeProjects) {
|
||||
if (project.kind !== "git") {
|
||||
continue;
|
||||
}
|
||||
const rootKey = normalizeWorkspaceId(project.rootPath);
|
||||
const group = projectsByRoot.get(rootKey) ?? [];
|
||||
group.push(project);
|
||||
projectsByRoot.set(rootKey, group);
|
||||
}
|
||||
|
||||
for (const duplicates of projectsByRoot.values()) {
|
||||
if (duplicates.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const canonical = chooseCanonicalProject(duplicates);
|
||||
const duplicateProjects = duplicates.filter(
|
||||
(project) => project.projectId !== canonical.projectId,
|
||||
);
|
||||
await this.mergeDuplicateProjectCustomName(canonical, duplicateProjects, changes);
|
||||
await Promise.all(
|
||||
duplicateProjects.flatMap((project) =>
|
||||
(workspacesByProject.get(project.projectId) ?? []).map(async (workspace) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const updatedWorkspace = {
|
||||
...workspace,
|
||||
projectId: canonical.projectId,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await this.workspaceRegistry.upsert(updatedWorkspace);
|
||||
changes.push({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: workspace.workspaceId,
|
||||
directory: workspace.cwd,
|
||||
fields: {
|
||||
projectId: canonical.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const canonicalSiblings = workspacesByProject.get(canonical.projectId) ?? [];
|
||||
canonicalSiblings.push(updatedWorkspace);
|
||||
workspacesByProject.set(canonical.projectId, canonicalSiblings);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const project of duplicateProjects) {
|
||||
workspacesByProject.set(project.projectId, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async mergeDuplicateProjectCustomName(
|
||||
canonical: PersistedProjectRecord,
|
||||
duplicateProjects: PersistedProjectRecord[],
|
||||
changes: ReconciliationChange[],
|
||||
): Promise<void> {
|
||||
if (canonical.customName) {
|
||||
return;
|
||||
}
|
||||
const customName = duplicateProjects.find((project) => project.customName)?.customName ?? null;
|
||||
if (!customName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.upsert({
|
||||
...canonical,
|
||||
customName,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
canonical.customName = customName;
|
||||
changes.push({
|
||||
kind: "project_updated",
|
||||
projectId: canonical.projectId,
|
||||
directory: canonical.rootPath,
|
||||
fields: { customName },
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcileProject(
|
||||
project: PersistedProjectRecord,
|
||||
siblings: PersistedWorkspaceRecord[],
|
||||
|
||||
@@ -512,6 +512,7 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
try {
|
||||
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
|
||||
cwd: request.cwd,
|
||||
projectId: request.projectId,
|
||||
worktreeSlug: request.worktreeSlug,
|
||||
firstAgentContext: normalizeFirstAgentContext(request),
|
||||
refName: request.refName,
|
||||
|
||||
@@ -1463,6 +1463,7 @@ export const FirstAgentContextSchema = z.object({
|
||||
export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
type: z.literal("create_paseo_worktree_request"),
|
||||
cwd: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
nameContext: z.string().optional(),
|
||||
attachments: AgentAttachmentsSchema.optional(),
|
||||
|
||||
Reference in New Issue
Block a user