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) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
| `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
|
## 7. Workspace Registry
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||||
|
|
||||||
export default function HostNewWorkspaceRoute() {
|
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 serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||||
|
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||||
|
|
||||||
if (!sourceDirectory) {
|
if (!sourceDirectory) {
|
||||||
return null;
|
return null;
|
||||||
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
sourceDirectory={sourceDirectory}
|
sourceDirectory={sourceDirectory}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1173,10 +1173,13 @@ function ProjectHeaderRow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.navigate(
|
router.navigate(
|
||||||
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href,
|
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
|
||||||
|
displayName,
|
||||||
|
projectId: project.projectKey,
|
||||||
|
}) as Href,
|
||||||
);
|
);
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
|
||||||
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const _toast = useToast();
|
const _toast = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ function resolveCheckoutRequest(
|
|||||||
interface NewWorkspaceScreenProps {
|
interface NewWorkspaceScreenProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
sourceDirectory: string;
|
sourceDirectory: string;
|
||||||
|
projectId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +497,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
export function NewWorkspaceScreen({
|
export function NewWorkspaceScreen({
|
||||||
serverId,
|
serverId,
|
||||||
sourceDirectory,
|
sourceDirectory,
|
||||||
|
projectId,
|
||||||
displayName: displayNameProp,
|
displayName: displayNameProp,
|
||||||
}: NewWorkspaceScreenProps) {
|
}: NewWorkspaceScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -711,6 +713,7 @@ export function NewWorkspaceScreen({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
|
...(projectId ? { projectId } : {}),
|
||||||
worktreeSlug: createNameId(),
|
worktreeSlug: createNameId(),
|
||||||
...(hasFirstAgentContext
|
...(hasFirstAgentContext
|
||||||
? {
|
? {
|
||||||
@@ -723,7 +726,7 @@ export function NewWorkspaceScreen({
|
|||||||
...checkoutRequest,
|
...checkoutRequest,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[currentBranch, selectedItem],
|
[currentBranch, projectId, selectedItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ensureWorkspace = useCallback(
|
const ensureWorkspace = useCallback(
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
|
|||||||
export function buildHostNewWorkspaceRoute(
|
export function buildHostNewWorkspaceRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
sourceDirectory: string,
|
sourceDirectory: string,
|
||||||
options?: { displayName?: string },
|
options?: { displayName?: string; projectId?: string },
|
||||||
) {
|
) {
|
||||||
const base = buildHostRootRoute(serverId);
|
const base = buildHostRootRoute(serverId);
|
||||||
if (base === "/") {
|
if (base === "/") {
|
||||||
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
|
|||||||
if (options?.displayName) {
|
if (options?.displayName) {
|
||||||
params.set("name", options.displayName);
|
params.set("name", options.displayName);
|
||||||
}
|
}
|
||||||
|
if (options?.projectId) {
|
||||||
|
params.set("projectId", options.projectId);
|
||||||
|
}
|
||||||
return `${base}/new?${params.toString()}` as const;
|
return `${base}/new?${params.toString()}` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
|
|||||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||||
workspaces,
|
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", () => {
|
it("redirects to the new workspace route when no sibling workspace target exists", () => {
|
||||||
@@ -65,7 +65,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
|
|||||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||||
workspaces,
|
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", () => {
|
it("redirects to the new workspace route instead of another workspace", () => {
|
||||||
@@ -85,7 +85,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
|
|||||||
archivedWorkspaceId: "/notes",
|
archivedWorkspaceId: "/notes",
|
||||||
workspaces,
|
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);
|
).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, {
|
return buildHostNewWorkspaceRoute(input.serverId, sourceDirectory, {
|
||||||
displayName: archivedWorkspace.projectDisplayName,
|
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(
|
const createPromise = client.createPaseoWorktree(
|
||||||
{
|
{
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
|
projectId: "remote:github.com/acme/project",
|
||||||
worktreeSlug: "review-pr-123",
|
worktreeSlug: "review-pr-123",
|
||||||
refName: "feature/worktree-base-ref",
|
refName: "feature/worktree-base-ref",
|
||||||
action: "checkout",
|
action: "checkout",
|
||||||
@@ -885,6 +886,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
|
|||||||
expect(request).toEqual({
|
expect(request).toEqual({
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
|
projectId: "remote:github.com/acme/project",
|
||||||
worktreeSlug: "review-pr-123",
|
worktreeSlug: "review-pr-123",
|
||||||
refName: "feature/worktree-base-ref",
|
refName: "feature/worktree-base-ref",
|
||||||
action: "checkout",
|
action: "checkout",
|
||||||
|
|||||||
@@ -259,7 +259,13 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
|||||||
|
|
||||||
export interface CreatePaseoWorktreeInput extends Pick<
|
export interface CreatePaseoWorktreeInput extends Pick<
|
||||||
CreatePaseoWorktreeRequest,
|
CreatePaseoWorktreeRequest,
|
||||||
"cwd" | "worktreeSlug" | "firstAgentContext" | "refName" | "action" | "githubPrNumber"
|
| "cwd"
|
||||||
|
| "projectId"
|
||||||
|
| "worktreeSlug"
|
||||||
|
| "firstAgentContext"
|
||||||
|
| "refName"
|
||||||
|
| "action"
|
||||||
|
| "githubPrNumber"
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
@@ -3012,6 +3018,7 @@ export class DaemonClient {
|
|||||||
message: {
|
message: {
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
|
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||||
worktreeSlug: input.worktreeSlug,
|
worktreeSlug: input.worktreeSlug,
|
||||||
...(input.firstAgentContext !== undefined
|
...(input.firstAgentContext !== undefined
|
||||||
? { firstAgentContext: input.firstAgentContext }
|
? { firstAgentContext: input.firstAgentContext }
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ import {
|
|||||||
shutdownProviders,
|
shutdownProviders,
|
||||||
} from "./agent/provider-registry.js";
|
} from "./agent/provider-registry.js";
|
||||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||||
|
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||||
import { FileBackedChatService } from "./chat/chat-service.js";
|
import { FileBackedChatService } from "./chat/chat-service.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
@@ -531,6 +532,26 @@ export async function createPaseoDaemon(
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
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();
|
await chatService.initialize();
|
||||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||||
const checkoutDiffManager = new CheckoutDiffManager({
|
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.
|
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||||
test.skipIf(isPlatform("win32"))(
|
test.skipIf(isPlatform("win32"))(
|
||||||
"reuses an existing worktree and still upserts the workspace",
|
"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 { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||||
import type { FirstAgentContext } from "../shared/messages.js";
|
import type { FirstAgentContext } from "../shared/messages.js";
|
||||||
|
|
||||||
export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput;
|
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeResult {
|
export interface CreatePaseoWorktreeResult {
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
@@ -60,6 +62,7 @@ export async function createPaseoWorktree(
|
|||||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||||
const workspace = await upsertWorkspaceForWorktree({
|
const workspace = await upsertWorkspaceForWorktree({
|
||||||
inputCwd: input.cwd,
|
inputCwd: input.cwd,
|
||||||
|
projectId: input.projectId,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
repoRoot: createdWorktree.repoRoot,
|
||||||
worktree: createdWorktree.worktree,
|
worktree: createdWorktree.worktree,
|
||||||
deps,
|
deps,
|
||||||
@@ -189,6 +192,7 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
|||||||
|
|
||||||
async function upsertWorkspaceForWorktree(options: {
|
async function upsertWorkspaceForWorktree(options: {
|
||||||
inputCwd: string;
|
inputCwd: string;
|
||||||
|
projectId?: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||||
@@ -202,6 +206,7 @@ async function upsertWorkspaceForWorktree(options: {
|
|||||||
);
|
);
|
||||||
const sourceProject = await resolveSourceProjectForWorktree({
|
const sourceProject = await resolveSourceProjectForWorktree({
|
||||||
inputCwd: normalizedInputCwd,
|
inputCwd: normalizedInputCwd,
|
||||||
|
projectId: options.projectId,
|
||||||
repoRoot: normalizedRepoRoot,
|
repoRoot: normalizedRepoRoot,
|
||||||
existingWorkspace,
|
existingWorkspace,
|
||||||
deps: options.deps,
|
deps: options.deps,
|
||||||
@@ -215,6 +220,7 @@ async function upsertWorkspaceForWorktree(options: {
|
|||||||
rootPath: sourceProject.rootPath,
|
rootPath: sourceProject.rootPath,
|
||||||
kind: sourceProject.kind,
|
kind: sourceProject.kind,
|
||||||
displayName: sourceProject.displayName,
|
displayName: sourceProject.displayName,
|
||||||
|
customName: sourceProject.customName,
|
||||||
createdAt: sourceProject.createdAt ?? now,
|
createdAt: sourceProject.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@@ -236,18 +242,88 @@ async function upsertWorkspaceForWorktree(options: {
|
|||||||
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSourceProjectForWorktree(options: {
|
interface SourceProjectForWorktree {
|
||||||
inputCwd: string;
|
|
||||||
repoRoot: string;
|
|
||||||
existingWorkspace: PersistedWorkspaceRecord | null;
|
|
||||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
|
||||||
}): Promise<{
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
kind: "git";
|
kind: "git";
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
customName: string | null;
|
||||||
createdAt: 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 =
|
const sourceWorkspace =
|
||||||
options.existingWorkspace ??
|
options.existingWorkspace ??
|
||||||
(await findWorkspaceForSource({
|
(await findWorkspaceForSource({
|
||||||
@@ -255,30 +331,19 @@ async function resolveSourceProjectForWorktree(options: {
|
|||||||
repoRoot: options.repoRoot,
|
repoRoot: options.repoRoot,
|
||||||
workspaceRegistry: options.deps.workspaceRegistry,
|
workspaceRegistry: options.deps.workspaceRegistry,
|
||||||
}));
|
}));
|
||||||
const sourceProject = sourceWorkspace
|
|
||||||
? await options.deps.projectRegistry.get(sourceWorkspace.projectId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (sourceWorkspace) {
|
if (sourceWorkspace) {
|
||||||
return {
|
return resolveWorkspaceProjectForWorktree({
|
||||||
projectId: sourceWorkspace.projectId,
|
sourceWorkspace,
|
||||||
rootPath: sourceProject?.rootPath ?? options.repoRoot,
|
repoRoot: options.repoRoot,
|
||||||
kind: "git",
|
projectRegistry: options.deps.projectRegistry,
|
||||||
displayName:
|
});
|
||||||
sourceProject?.displayName ?? deriveProjectGroupingName(sourceWorkspace.projectId),
|
|
||||||
createdAt: sourceProject?.createdAt ?? null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingFallbackProject = await options.deps.projectRegistry.get(options.repoRoot);
|
return resolveFallbackProjectForWorktree({
|
||||||
return {
|
repoRoot: options.repoRoot,
|
||||||
projectId: options.repoRoot,
|
projectRegistry: options.deps.projectRegistry,
|
||||||
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
|
});
|
||||||
kind: "git",
|
|
||||||
displayName:
|
|
||||||
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
|
|
||||||
createdAt: existingFallbackProject?.createdAt ?? null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findWorkspaceForSource(options: {
|
async function findWorkspaceForSource(options: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -336,6 +336,117 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
expect(workspaces.get("w1")!.kind).toBe("local_checkout");
|
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 () => {
|
test("updates project display name when git remote changes", async () => {
|
||||||
const dir = createTempGitRepo("reconcile-remote-");
|
const dir = createTempGitRepo("reconcile-remote-");
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
PersistedWorkspaceRecord,
|
PersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
@@ -19,6 +20,21 @@ function deriveWorkspaceKindFromMetadata(metadata: {
|
|||||||
return "local_checkout";
|
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 =
|
export type ReconciliationChange =
|
||||||
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
||||||
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
|
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
|
||||||
@@ -26,13 +42,15 @@ export type ReconciliationChange =
|
|||||||
kind: "project_updated";
|
kind: "project_updated";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">>;
|
fields: Partial<
|
||||||
|
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath" | "customName">
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "workspace_updated";
|
kind: "workspace_updated";
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">>;
|
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "displayName" | "kind">>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReconciliationResult {
|
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 orphanedProjects = activeProjects.filter((project) => {
|
||||||
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||||
return siblings.length === 0;
|
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) => {
|
const projectsToReconcile = activeProjects.filter((project) => {
|
||||||
if (project.archivedAt) return false;
|
if (project.archivedAt) return false;
|
||||||
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||||
@@ -183,6 +204,91 @@ export class WorkspaceReconciliationService {
|
|||||||
return { changesApplied: changes, durationMs: Date.now() - start };
|
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(
|
private async reconcileProject(
|
||||||
project: PersistedProjectRecord,
|
project: PersistedProjectRecord,
|
||||||
siblings: PersistedWorkspaceRecord[],
|
siblings: PersistedWorkspaceRecord[],
|
||||||
|
|||||||
@@ -512,6 +512,7 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
try {
|
try {
|
||||||
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
|
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
|
projectId: request.projectId,
|
||||||
worktreeSlug: request.worktreeSlug,
|
worktreeSlug: request.worktreeSlug,
|
||||||
firstAgentContext: normalizeFirstAgentContext(request),
|
firstAgentContext: normalizeFirstAgentContext(request),
|
||||||
refName: request.refName,
|
refName: request.refName,
|
||||||
|
|||||||
@@ -1463,6 +1463,7 @@ export const FirstAgentContextSchema = z.object({
|
|||||||
export const CreatePaseoWorktreeRequestSchema = z.object({
|
export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||||
type: z.literal("create_paseo_worktree_request"),
|
type: z.literal("create_paseo_worktree_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
worktreeSlug: z.string().optional(),
|
worktreeSlug: z.string().optional(),
|
||||||
nameContext: z.string().optional(),
|
nameContext: z.string().optional(),
|
||||||
attachments: AgentAttachmentsSchema.optional(),
|
attachments: AgentAttachmentsSchema.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user