diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 134b7a2de..9fb579e20 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test, vi } from "vitest"; @@ -151,6 +151,41 @@ test("repairs a legacy source workspace whose project record is missing", async expect(createRealpathAwarePathMatcher(repoDir)(repairedProject?.rootPath ?? "")).toBe(true); }); +test("uses an equivalent source workspace path when creating a worktree", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const sourceDir = path.join(repoDir, "app"); + mkdirSync(sourceDir); + writeFileSync(path.join(repoDir, "app", ".gitkeep"), ""); + const deps = createDeps(); + const sourceProject = createPersistedProjectRecordForTest({ + projectId: "prj_source-folder", + rootPath: sourceDir, + displayName: "app", + }); + const sourceWorkspace = createPersistedWorkspaceRecordForTest({ + workspaceId: "ws-source-folder", + projectId: sourceProject.projectId, + cwd: `${sourceDir}${path.sep}`, + kind: "local_checkout", + displayName: "app", + }); + deps.projects.set(sourceProject.projectId, sourceProject); + deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace); + + const result = await createPaseoWorktree( + { + cwd: sourceDir, + worktreeSlug: "equivalent-source", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.workspace.projectId).toBe(sourceProject.projectId); +}); + 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); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index fa93cdde4..ba07b9893 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,5 +1,6 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; import { resolve } from "node:path"; +import { areEquivalentPaths } from "../utils/path.js"; import { type PersistedProjectRecord, type PersistedWorkspaceRecord, @@ -314,8 +315,12 @@ async function findWorkspaceForSource(options: { }): Promise { const workspaces = await options.workspaceRegistry.list(); return ( - workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ?? - workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ?? + workspaces.find( + (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, options.inputCwd), + ) ?? + workspaces.find( + (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, options.repoRoot), + ) ?? null ); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d4cc37cef..5223c4e8d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -897,6 +897,7 @@ export class Session { scriptRuntimeStore: this.scriptRuntimeStore, terminalManager: this.terminalManager, workspaceRegistry: this.workspaceRegistry, + projectRegistry: this.projectRegistry, workspaceGitService: this.workspaceGitService, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, @@ -3764,7 +3765,7 @@ export class Session { statusEnteredAt: null, activityAt: null, diffStat, - scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace.workspaceId, workspace.cwd), + scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord ? { project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord), @@ -3868,7 +3869,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null; includeGitData: boolean; }): Promise { - if (input.includeGitData && input.projectRecord?.kind === "git") { + if (input.includeGitData && input.workspace.kind !== "directory") { return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord); } return this.describeWorkspaceRecord(input.workspace, input.projectRecord); @@ -5130,10 +5131,10 @@ export class Session { // Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's // scripts snapshot through here; the workspace-scripts module owns the payload assembly. private buildWorkspaceScriptPayloadSnapshot( - workspaceId: string, - workspaceDirectory: string, + workspace: PersistedWorkspaceRecord, + project: PersistedProjectRecord | null, ): WorkspaceDescriptorPayload["scripts"] { - return this.workspaceScripts.buildSnapshot(workspaceId, workspaceDirectory); + return this.workspaceScripts.buildSnapshot(workspace, project); } private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index ab08bb952..17766fde6 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5756,6 +5756,40 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc expect(descriptors).toEqual([baselineDescriptor]); }); +test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => { + const session = createSessionForWorkspaceTests(); + const project = createPersistedProjectRecord({ + projectId: "proj-explicit-directory", + rootPath: "/tmp/explicit-directory", + kind: "non_git", + displayName: "directory project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-explicit-checkout", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD); + + const descriptors = Array.from( + (await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(), + ) as Array<{ gitRuntime?: { currentBranch: string | null }; githubRuntime?: unknown }>; + + expect(descriptors[0]).toMatchObject({ + gitRuntime: { currentBranch: "main" }, + githubRuntime: expect.any(Object), + }); +}); + test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => { const setupSession = () => { const session = createSessionForWorkspaceTests(); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index cac7c52d2..97580b86f 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pino } from "pino"; @@ -6,7 +6,12 @@ import { afterEach, describe, expect, test } from "vitest"; import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; -import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../../workspace-registry.js"; import { createNoGitWorkspaceRuntimeSnapshot } from "../../test-utils/workspace-git-service-stub.js"; import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { @@ -14,6 +19,7 @@ import type { WorktreeScriptResult, } from "../../worktree-bootstrap.js"; import { createWorkspaceScriptsService } from "./workspace-scripts-service.js"; +import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js"; // The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug}, // WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected @@ -32,7 +38,15 @@ function fakeWorkspaceRegistry( }; } -function fakeGitService(projectSlug = "paseo") { +function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick { + return { + async get() { + return record; + }, + }; +} + +function fakeGitService() { const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo"); snapshot.git = { ...snapshot.git, @@ -47,9 +61,6 @@ function fakeGitService(projectSlug = "paseo") { peekSnapshot() { return snapshot; }, - async getProjectSlug() { - return projectSlug; - }, }; } @@ -62,6 +73,7 @@ interface BuildOptions { scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null; terminalManager?: TerminalManager | null; workspace?: PersistedWorkspaceRecord | null; + project?: PersistedProjectRecord | null; spawnThrows?: string; } @@ -85,6 +97,7 @@ function buildService(options: BuildOptions = {}) { terminalManager: options.terminalManager === undefined ? availableTerminalManager : options.terminalManager, workspaceRegistry: fakeWorkspaceRegistry(workspace), + projectRegistry: fakeProjectRegistry(options.project ?? null), workspaceGitService: fakeGitService(), getDaemonTcpPort: () => 6767, getDaemonTcpHost: () => "127.0.0.1", @@ -128,28 +141,34 @@ afterEach(() => { }); describe("buildSnapshot", () => { - test("returns no scripts when the service proxy is unavailable", () => { + test("returns no scripts when the service proxy is unavailable", async () => { const { service } = buildService({ serviceProxy: null }); - expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]); + expect( + service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + ).toEqual([]); }); - test("returns no scripts when the runtime store is unavailable", () => { + test("returns no scripts when the runtime store is unavailable", async () => { const { service } = buildService({ scriptRuntimeStore: null }); - expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]); + expect( + service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + ).toEqual([]); }); - test("returns no scripts for a workspace without a paseo.json", () => { + test("returns no scripts for a workspace without a paseo.json", async () => { const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-")); tempDirs.push(dir); const { service } = buildService(); - expect(service.buildSnapshot("ws-1", dir)).toEqual([]); + expect( + service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), + ).toEqual([]); }); }); describe("emitStatusUpdate", () => { - test("emits one script_status_update carrying the snapshot", () => { + test("emits one script_status_update carrying the snapshot", async () => { const { service, emitted } = buildService(); - service.emitStatusUpdate("ws-1", "/tmp/repo"); + await service.emitStatusUpdate("ws-1", "/tmp/repo"); expect(emitted).toEqual([ { type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } }, ]); @@ -223,6 +242,101 @@ describe("start", () => { }); }); + test("uses the exact project root for a service hostname", async () => { + const workspace = { + workspaceId: "ws-app", + projectId: "prj-app", + cwd: "/repo/apps/app", + } as PersistedWorkspaceRecord; + const project = { + projectId: "prj-app", + rootPath: "/repo/apps/app", + kind: "git", + displayName: "app", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + } as PersistedProjectRecord; + const { service, spawnCalls } = buildService({ workspace, project }); + + await service.start({ ...request, workspaceId: workspace.workspaceId }); + + expect(spawnCalls[0]).toMatchObject({ + projectSlug: deriveProjectServiceSlug(project), + }); + }); + + test("keeps same-named service projects distinct", async () => { + const projectA = { + projectId: "prj-app-a", + rootPath: "/repo-a/app", + kind: "git", + displayName: "app", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + } as PersistedProjectRecord; + const projectB = { ...projectA, projectId: "prj-app-b", rootPath: "/repo-b/app" }; + const workspaceA = { + workspaceId: "ws-app-a", + projectId: projectA.projectId, + cwd: projectA.rootPath, + } as PersistedWorkspaceRecord; + const workspaceB = { + workspaceId: "ws-app-b", + projectId: projectB.projectId, + cwd: projectB.rootPath, + } as PersistedWorkspaceRecord; + const first = buildService({ workspace: workspaceA, project: projectA }); + const second = buildService({ workspace: workspaceB, project: projectB }); + + await first.service.start({ ...request, workspaceId: workspaceA.workspaceId }); + await second.service.start({ ...request, workspaceId: workspaceB.workspaceId }); + + expect(first.spawnCalls[0]?.projectSlug).not.toBe(second.spawnCalls[0]?.projectSlug); + }); + + test("predicts the same service hostname that start registers", async () => { + const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-")); + tempDirs.push(directory); + writeFileSync( + join(directory, "paseo.json"), + JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }), + ); + const project = { + projectId: "prj_hostname", + rootPath: directory, + kind: "git", + displayName: "app", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + } as PersistedProjectRecord; + const workspace = { + workspaceId: "ws-hostname", + projectId: project.projectId, + cwd: directory, + } as PersistedWorkspaceRecord; + const serviceProxy = createServiceProxySubsystem({ logger }); + const { service, spawnCalls } = buildService({ workspace, project, serviceProxy }); + + const snapshot = service.buildSnapshot(workspace, project); + await service.start({ ...request, workspaceId: workspace.workspaceId }); + + const started = spawnCalls[0]!; + expect(snapshot[0]?.hostname).toBe( + serviceProxy.projectWorkspaceService({ + projectSlug: started.projectSlug, + branchName: started.branchName, + scriptName: started.scriptName, + daemonPort: started.daemonPort, + }).hostname, + ); + }); + test("reports the launcher error when spawning fails", async () => { const { service, emitted } = buildService({ spawnThrows: "boom" }); await service.start(request); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 60d8ad250..5a2be10b2 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -9,7 +9,12 @@ import type { ServiceProxySubsystem } from "../../service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { ScriptHealthState } from "../../script-health-monitor.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; -import type { WorkspaceRegistry } from "../../workspace-registry.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../../workspace-registry.js"; import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, @@ -18,7 +23,7 @@ import { buildWorkspaceScriptPayloads, readPaseoConfigForProjection, } from "../../script-status-projection.js"; -import { deriveProjectSlug } from "../../workspace-git-metadata.js"; +import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js"; type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; @@ -32,18 +37,22 @@ type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; * that assembly and guard across the session. */ export interface WorkspaceScriptsService { - buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload; - emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void; + buildSnapshot( + workspace: PersistedWorkspaceRecord, + project?: PersistedProjectRecord | null, + ): WorkspaceScriptsPayload; + emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; start(request: StartWorkspaceScriptRequest): Promise; } -type WorkspaceScriptsGitSource = Pick; +type WorkspaceScriptsGitSource = Pick; export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; terminalManager: TerminalManager | null; workspaceRegistry: Pick; + projectRegistry: Pick; workspaceGitService: WorkspaceScriptsGitSource; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; @@ -58,6 +67,7 @@ export function createWorkspaceScriptsService(deps: { scriptRuntimeStore, terminalManager, workspaceRegistry, + projectRegistry, workspaceGitService, getDaemonTcpPort, getDaemonTcpHost, @@ -68,45 +78,54 @@ export function createWorkspaceScriptsService(deps: { spawnWorkspaceScript, } = deps; - function resolveGitMetadata(workspaceDirectory: string) { + function resolveGitMetadata( + workspaceDirectory: string, + project: { projectId: string; rootPath: string } | null, + ) { const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory); if (!snapshot) { return undefined; } return { - projectSlug: deriveProjectSlug( - workspaceDirectory, - snapshot.git.isGit ? snapshot.git.remoteUrl : null, - ), + projectSlug: project + ? deriveProjectServiceSlug(project) + : deriveProjectSlug(workspaceDirectory, snapshot.git.isGit ? snapshot.git.remoteUrl : null), currentBranch: snapshot.git.currentBranch, }; } - function buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload { + function buildSnapshot( + workspace: PersistedWorkspaceRecord, + project: PersistedProjectRecord | null = null, + ): WorkspaceScriptsPayload { if (!serviceProxy || !scriptRuntimeStore) { return []; } return buildWorkspaceScriptPayloads({ - workspaceId, - workspaceDirectory, - paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.cwd, + paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger), serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: getDaemonTcpPort?.() ?? null, serviceProxyPublicBaseUrl, - gitMetadata: resolveGitMetadata(workspaceDirectory), + gitMetadata: resolveGitMetadata(workspace.cwd, project), resolveHealth: resolveScriptHealth ?? undefined, }); } - function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void { - emit({ - type: "script_status_update", - payload: { - workspaceId, - scripts: buildSnapshot(workspaceId, workspaceDirectory), - }, - }); + async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise { + try { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace) return; + const project = await projectRegistry.get(workspace.projectId); + emit({ + type: "script_status_update", + payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, + }); + } catch (error) { + logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); + } } async function start(request: StartWorkspaceScriptRequest): Promise { @@ -119,7 +138,13 @@ export function createWorkspaceScriptsService(deps: { if (!workspace) { throw new Error(`Workspace not found: ${request.workspaceId}`); } - const projectSlug = await workspaceGitService.getProjectSlug(workspace.cwd); + const project = await projectRegistry.get(workspace.projectId); + const projectSlug = project + ? deriveProjectServiceSlug(project) + : deriveProjectSlug( + workspace.cwd, + workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null, + ); const branchName = workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null; const serviceResult = await spawnWorkspaceScript({ @@ -136,11 +161,11 @@ export function createWorkspaceScriptsService(deps: { terminalManager, logger, onLifecycleChanged: () => { - emitStatusUpdate(workspace.workspaceId, workspace.cwd); + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); }, }); - emitStatusUpdate(workspace.workspaceId, workspace.cwd); + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); emit({ type: "start_workspace_script_response", payload: { diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 0571de998..11cf3637f 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -233,6 +233,68 @@ describe("archiveByScope", () => { expect(existsSync(worktree.worktreePath)).toBe(true); }); + test("workspace scope keeps a worktree for an active workspace in a subdirectory", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-sibling"); + const sourceWorkspaceId = "ws-subdirectory-source"; + const siblingWorkspaceId = "ws-subdirectory-sibling"; + const siblingDirectory = path.join(worktree.worktreePath, "packages", "app"); + mkdirSync(siblingDirectory, { recursive: true }); + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { workspaceId: sourceWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: siblingWorkspaceId, cwd: siblingDirectory, kind: "local_checkout" }, + ], + }), + { + scope: { kind: "workspace", workspaceId: sourceWorkspaceId }, + repoRoot: repoDir, + requestId: "req-subdirectory-sibling", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [sourceWorkspaceId], + removedDirectory: false, + }); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("archiving a subdirectory workspace keeps its active worktree root", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-target"); + const rootWorkspaceId = "ws-subdirectory-root"; + const subdirectoryWorkspaceId = "ws-subdirectory-target"; + const subdirectory = path.join(worktree.worktreePath, "packages", "app"); + mkdirSync(subdirectory, { recursive: true }); + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { workspaceId: rootWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: subdirectoryWorkspaceId, cwd: subdirectory, kind: "local_checkout" }, + ], + }), + { + scope: { kind: "workspace", workspaceId: subdirectoryWorkspaceId }, + repoRoot: repoDir, + requestId: "req-subdirectory-target", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [subdirectoryWorkspaceId], + removedDirectory: false, + }); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + test("worktree scope archives every workspace on the directory and removes it", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 54be41b10..a41dbfca2 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -171,7 +171,14 @@ async function resolveArchiveTargets( ); return { targetDir: null, targetWorkspaceIds: [] }; } - return { targetDir: resolve(record.cwd), targetWorkspaceIds: [workspaceId] }; + const worktree = await resolvePaseoWorktreeRootForCwd(record.cwd, { + paseoHome: dependencies.paseoHome, + worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + }); + return { + targetDir: worktree?.worktreePath ?? resolve(record.cwd), + targetWorkspaceIds: [workspaceId], + }; } let targetPath = scope.targetPath; @@ -237,7 +244,15 @@ async function maybeRemoveDirectory( } const remainingActive = await dependencies.listActiveWorkspaces(); - if (!isDirectoryUnreferenced(remainingActive, targetDir, new Set(archivedWorkspaceIds))) { + if ( + !(await isDirectoryUnreferenced( + remainingActive, + targetDir, + new Set(archivedWorkspaceIds), + dependencies, + request, + )) + ) { return false; } @@ -326,16 +341,23 @@ export async function archiveWorkspaceContents( // EXACTLY one last-reference predicate in the module. True when, after archiving // the in-scope records, no active workspace still points at targetDir. Derived // from records each call — no stored counter. -function isDirectoryUnreferenced( +async function isDirectoryUnreferenced( activeWorkspaces: ActiveWorkspaceRef[], targetDir: string, archivedWorkspaceIds: ReadonlySet, -): boolean { + dependencies: Pick, + request: Pick, +): Promise { const target = resolve(targetDir); - return !activeWorkspaces.some( - (workspace) => - !archivedWorkspaceIds.has(workspace.workspaceId) && resolve(workspace.cwd) === target, - ); + for (const workspace of activeWorkspaces) { + if (archivedWorkspaceIds.has(workspace.workspaceId)) continue; + const worktree = await resolvePaseoWorktreeRootForCwd(workspace.cwd, { + paseoHome: dependencies.paseoHome, + worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + }); + if (resolve(worktree?.worktreePath ?? workspace.cwd) === target) return false; + } + return true; } export async function killTerminalsForWorkspace( diff --git a/packages/server/src/server/workspace-git-metadata.test.ts b/packages/server/src/server/workspace-git-metadata.test.ts index 5b245a7d7..441246420 100644 --- a/packages/server/src/server/workspace-git-metadata.test.ts +++ b/packages/server/src/server/workspace-git-metadata.test.ts @@ -5,7 +5,11 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { deriveProjectSlug, parseGitHubRepoNameFromRemote } from "./workspace-git-metadata.js"; +import { + deriveProjectServiceSlug, + deriveProjectSlug, + parseGitHubRepoNameFromRemote, +} from "./workspace-git-metadata.js"; function runGit(cwd: string, args: string[]): void { execFileSync("git", args, { @@ -166,3 +170,13 @@ describe("deriveProjectSlug", () => { expect(deriveProjectSlug(cwd)).toBe("untitled"); }); }); + +describe("deriveProjectServiceSlug", () => { + test("keeps same-basename exact projects distinct and stable", () => { + const first = { projectId: "prj_aaaaaaaaaaaaaaaa", rootPath: "/repo-a/app" }; + const second = { projectId: "prj_bbbbbbbbbbbbbbbb", rootPath: "/repo-b/app" }; + + expect(deriveProjectServiceSlug(first)).toBe(deriveProjectServiceSlug(first)); + expect(deriveProjectServiceSlug(first)).not.toBe(deriveProjectServiceSlug(second)); + }); +}); diff --git a/packages/server/src/server/workspace-git-metadata.ts b/packages/server/src/server/workspace-git-metadata.ts index 287d8881f..69247ca14 100644 --- a/packages/server/src/server/workspace-git-metadata.ts +++ b/packages/server/src/server/workspace-git-metadata.ts @@ -1,4 +1,5 @@ import { basename } from "path"; +import { createHash } from "node:crypto"; import { parseGitHubRemoteUrl } from "../utils/github-remote.js"; import { slugify } from "../utils/worktree.js"; @@ -20,3 +21,8 @@ export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null): const sourceName = githubRepoName ?? basename(cwd); return slugify(sourceName) || "untitled"; } + +export function deriveProjectServiceSlug(project: { projectId: string; rootPath: string }): string { + const identity = createHash("sha256").update(project.projectId).digest("hex").slice(0, 8); + return `${deriveProjectSlug(project.rootPath)}-${identity}`; +}