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