mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): preserve nested worktree lifecycle
This commit is contained in:
@@ -3,11 +3,7 @@ import type pino from "pino";
|
||||
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import {
|
||||
archiveByScope,
|
||||
type ActiveWorkspaceRef,
|
||||
resolveWorkspaceIdAtPath,
|
||||
} from "../workspace-archive-service.js";
|
||||
import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js";
|
||||
import type {
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
@@ -42,6 +38,10 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
type AutoArchiveTarget =
|
||||
| { kind: "agent-only" }
|
||||
| { kind: "created-worktree"; result: CreatePaseoWorktreeWorkflowResult };
|
||||
|
||||
export class CreateAgentLifecycleDispatch {
|
||||
private readonly autoArchiveAgentIds = new Set<string>();
|
||||
|
||||
@@ -72,10 +72,10 @@ export class CreateAgentLifecycleDispatch {
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerAutoArchiveOnTerminalState(input.agentId, {
|
||||
worktreePath: input.createdWorktree?.worktree.worktreePath ?? null,
|
||||
repoRoot: input.createdWorktree?.repoRoot ?? null,
|
||||
});
|
||||
this.registerAutoArchiveOnTerminalState(
|
||||
input.agentId,
|
||||
toAutoArchiveTarget(input.createdWorktree),
|
||||
);
|
||||
}
|
||||
|
||||
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
|
||||
@@ -89,8 +89,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
await this.archiveAutoCreatedWorktree({
|
||||
agentId: null,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
createdWorktree,
|
||||
}).catch((archiveError) => {
|
||||
this.dependencies.logger.warn(
|
||||
{
|
||||
@@ -143,10 +142,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
}
|
||||
}
|
||||
|
||||
private registerAutoArchiveOnTerminalState(
|
||||
agentId: string,
|
||||
options: { worktreePath: string | null; repoRoot: string | null },
|
||||
): void {
|
||||
private registerAutoArchiveOnTerminalState(agentId: string, target: AutoArchiveTarget): void {
|
||||
const unsubscribe = this.dependencies.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_stream") {
|
||||
@@ -160,27 +156,23 @@ export class CreateAgentLifecycleDispatch {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
void this.autoArchiveAgentOnce(agentId, options);
|
||||
void this.autoArchiveAgentOnce(agentId, target);
|
||||
},
|
||||
{ agentId, replayState: false },
|
||||
);
|
||||
}
|
||||
|
||||
private async autoArchiveAgentOnce(
|
||||
agentId: string,
|
||||
options: { worktreePath: string | null; repoRoot: string | null },
|
||||
): Promise<void> {
|
||||
private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise<void> {
|
||||
if (this.autoArchiveAgentIds.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
this.autoArchiveAgentIds.add(agentId);
|
||||
|
||||
try {
|
||||
if (options.worktreePath) {
|
||||
if (target.kind === "created-worktree") {
|
||||
await this.archiveAutoCreatedWorktree({
|
||||
agentId,
|
||||
worktreePath: options.worktreePath,
|
||||
repoRoot: options.repoRoot,
|
||||
createdWorktree: target.result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -193,10 +185,11 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
private async archiveAutoCreatedWorktree(options: {
|
||||
agentId: string | null;
|
||||
worktreePath: string;
|
||||
repoRoot: string | null;
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult;
|
||||
}): Promise<void> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
|
||||
const { createdWorktree } = options;
|
||||
const worktreePath = createdWorktree.worktree.worktreePath;
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(worktreePath, {
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
});
|
||||
@@ -204,49 +197,41 @@ export class CreateAgentLifecycleDispatch {
|
||||
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
|
||||
}
|
||||
|
||||
const workspaceId = await resolveWorkspaceIdAtPath(
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
github: this.dependencies.github,
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
|
||||
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
|
||||
sessionLogger: this.dependencies.logger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId },
|
||||
repoRoot: createdWorktree.repoRoot ?? ownership.repoRoot ?? null,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
requestId: randomUUID(),
|
||||
},
|
||||
options.worktreePath,
|
||||
);
|
||||
|
||||
if (!workspaceId) {
|
||||
this.dependencies.logger.warn(
|
||||
{ worktreePath: options.worktreePath },
|
||||
"Could not resolve workspace for auto-archive; skipping",
|
||||
);
|
||||
} else {
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
github: this.dependencies.github,
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds:
|
||||
this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
|
||||
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
|
||||
sessionLogger: this.dependencies.logger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
requestId: randomUUID(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (options.agentId) {
|
||||
this.dependencies.emitAgentRemove(options.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toAutoArchiveTarget(
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null,
|
||||
): AutoArchiveTarget {
|
||||
return createdWorktree
|
||||
? { kind: "created-worktree", result: createdWorktree }
|
||||
: { kind: "agent-only" };
|
||||
}
|
||||
|
||||
@@ -217,6 +217,62 @@ test("creates a worktree workspace at the selected project subdirectory", async
|
||||
});
|
||||
});
|
||||
|
||||
test("maps a nested cwd from an existing Paseo worktree into the next worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const deps = createDeps();
|
||||
const source = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "source-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const sourceCwd = path.join(source.worktree.worktreePath, "packages", "app");
|
||||
mkdirSync(sourceCwd, { recursive: true });
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceCwd,
|
||||
worktreeSlug: "nested-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.workspace.cwd).toBe(path.join(created.worktree.worktreePath, "packages", "app"));
|
||||
expect(deps.workspaces.get(created.workspace.workspaceId)).toEqual(created.workspace);
|
||||
});
|
||||
|
||||
test("rejects source checkout planning before creating a worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const deps = createDeps();
|
||||
deps.workspaceGitService.getCheckout = async () => {
|
||||
throw new Error("source checkout unavailable");
|
||||
};
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "must-not-create",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("source checkout unavailable");
|
||||
|
||||
expect(existsSync(path.join(paseoHome, "worktrees"))).toBe(false);
|
||||
expect(Array.from(deps.workspaces.values())).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { resolve } from "node:path";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import { areEquivalentPaths, getRealpathAwareRelativePath } from "../utils/path.js";
|
||||
import {
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import {
|
||||
mapWorkspaceCwdToWorktree,
|
||||
mapWorkspaceRelativeCwdToWorktree,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
@@ -66,12 +66,14 @@ export async function createPaseoWorktree(
|
||||
input: CreatePaseoWorktreeInput,
|
||||
deps: CreatePaseoWorktreeDeps,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
inputCwd: workspaceCwdPlan.inputCwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
worktree: createdWorktree.worktree,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
@@ -89,6 +91,20 @@ export async function createPaseoWorktree(
|
||||
};
|
||||
}
|
||||
|
||||
async function planWorkspaceCwdForWorktree(
|
||||
inputCwd: string,
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
|
||||
): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> {
|
||||
const normalizedInputCwd = resolve(inputCwd);
|
||||
const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd);
|
||||
const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd;
|
||||
const relativeWorkspaceCwd = getRealpathAwareRelativePath(sourceWorktreePath, normalizedInputCwd);
|
||||
if (relativeWorkspaceCwd === null) {
|
||||
throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`);
|
||||
}
|
||||
return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd };
|
||||
}
|
||||
|
||||
export async function attemptFirstAgentBranchAutoName(options: {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext | undefined;
|
||||
@@ -217,6 +233,7 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
relativeWorkspaceCwd: string;
|
||||
worktree: WorktreeConfig;
|
||||
baseBranch?: string | null;
|
||||
title?: string | null;
|
||||
@@ -227,9 +244,8 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedInputCwd = resolve(options.inputCwd);
|
||||
const normalizedRepoRoot = resolve(options.repoRoot);
|
||||
const normalizedCwd = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: normalizedRepoRoot,
|
||||
workspaceCwd: normalizedInputCwd,
|
||||
const normalizedCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: options.relativeWorkspaceCwd,
|
||||
targetWorktreePath: options.worktree.worktreePath,
|
||||
});
|
||||
// Creation never deduplicates by directory: a worktree directory may back
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
@@ -8,6 +8,7 @@ import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
|
||||
import type { CreateAgentOptions } from "./test-utils/index.js";
|
||||
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
@@ -42,6 +43,18 @@ function createGitRepo(): string {
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function createGitRepoWithNestedDirectory(): string {
|
||||
const repoDir = createGitRepo();
|
||||
mkdirSync(path.join(repoDir, "packages", "app"), { recursive: true });
|
||||
writeFileSync(path.join(repoDir, "packages", "app", ".gitkeep"), "");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested app"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
async function expectAgentAbsentFromActiveList(agentId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
|
||||
async function createAgentInBranchOffWorktree(options?: {
|
||||
autoArchive?: boolean;
|
||||
branchName?: string;
|
||||
repoDir?: string;
|
||||
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
|
||||
const repoDir = createGitRepo();
|
||||
const repoDir = options?.repoDir ?? createGitRepo();
|
||||
const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
@@ -142,6 +156,109 @@ test("create_agent_request creates a worktree and auto-archives both after the f
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
test("create_agent_request auto-archives a nested workspace from an existing Paseo worktree", async () => {
|
||||
const repoDir = createGitRepoWithNestedDirectory();
|
||||
const source = await createAgentInBranchOffWorktree({ branchName: "nested-source", repoDir });
|
||||
await ctx.client.waitForFinish(source.agentId, 10000);
|
||||
const nestedCwd = path.join(source.worktreePath, "packages", "app");
|
||||
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: nestedCwd,
|
||||
},
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "nested-auto-archive",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
initialPrompt: "Say done.",
|
||||
});
|
||||
|
||||
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: created.cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(
|
||||
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(created.cwd),
|
||||
).toBe(true);
|
||||
await ctx.client.waitForFinish(created.id, 10000);
|
||||
|
||||
await expectAgentAbsentFromActiveList(created.id);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
const matchesCreatedWorkspace = createRealpathAwarePathMatcher(created.cwd);
|
||||
return workspaces.entries.some((workspace) =>
|
||||
matchesCreatedWorkspace(workspace.workspaceDirectory),
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(false);
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
expect(existsSync(source.worktreePath)).toBe(true);
|
||||
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
|
||||
}, 30000);
|
||||
|
||||
test("failed nested worktree creation cleans up the created workspace and backing directory", async () => {
|
||||
const repoDir = createGitRepoWithNestedDirectory();
|
||||
const source = await createAgentInBranchOffWorktree({
|
||||
branchName: "nested-failure-source",
|
||||
repoDir,
|
||||
});
|
||||
await ctx.client.waitForFinish(source.agentId, 10000);
|
||||
const nestedCwd = path.join(source.worktreePath, "packages", "app");
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({
|
||||
config: { provider: "unknown-provider", cwd: nestedCwd },
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "nested-failure-cleanup",
|
||||
base: "main",
|
||||
},
|
||||
initialPrompt: "This agent cannot be created.",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await ctx.client.getPaseoWorktreeList({ cwd: source.repoDir });
|
||||
return (
|
||||
listed.worktrees.length === 1 &&
|
||||
createRealpathAwarePathMatcher(source.worktreePath)(
|
||||
listed.worktrees[0]?.worktreePath ?? "",
|
||||
)
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
return (
|
||||
workspaces.entries.length === 1 &&
|
||||
createRealpathAwarePathMatcher(source.worktreePath)(
|
||||
workspaces.entries[0]?.workspaceDirectory ?? "",
|
||||
)
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
|
||||
}, 30000);
|
||||
|
||||
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
const created = await ctx.client.createAgent({
|
||||
|
||||
@@ -138,6 +138,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an
|
||||
test("re-opening an active workspace refreshes its checkout metadata", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
|
||||
gitRoots.add(repo);
|
||||
gitBranches.set(repo, "feature/refresh");
|
||||
|
||||
@@ -147,6 +148,8 @@ test("re-opening an active workspace refreshes its checkout metadata", async ()
|
||||
workspaceId: first.workspaceId,
|
||||
kind: "local_checkout",
|
||||
branch: "feature/refresh",
|
||||
displayName: "feature/refresh",
|
||||
title: "Pinned work",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
@@ -279,22 +282,26 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("reopening an archived workspace refreshes git-derived kind and branch", async () => {
|
||||
test("reopening an archived workspace refreshes its checkout-derived fields", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
gitRoots.delete(repo);
|
||||
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toEqual({
|
||||
...created,
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: created.workspaceId,
|
||||
projectId: created.projectId,
|
||||
title: "Pinned archived work",
|
||||
kind: "directory",
|
||||
branch: null,
|
||||
displayName: "repo",
|
||||
archivedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(reopened.updatedAt).toEqual(expect.any(String));
|
||||
expect(await workspaceRegistry.get(created.workspaceId)).toEqual(reopened);
|
||||
});
|
||||
|
||||
|
||||
@@ -240,13 +240,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
if (workspace.archivedAt && checkout) {
|
||||
next = {
|
||||
...workspace,
|
||||
kind: deriveWorkspaceKind(checkout),
|
||||
branch:
|
||||
checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD"
|
||||
? checkout.currentBranch
|
||||
: null,
|
||||
isPaseoOwnedWorktree: checkout.isGit && checkout.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: checkout.isGit ? checkout.mainRepoRoot : null,
|
||||
...checkoutDerivedWorkspaceFields(workspace, checkout),
|
||||
archivedAt: null,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
@@ -273,33 +267,44 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
if (project && !project.archivedAt) {
|
||||
await refreshProjectKind(project, workspace.cwd, checkout);
|
||||
}
|
||||
const kind = deriveWorkspaceKind(checkout);
|
||||
const branch =
|
||||
checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD"
|
||||
? checkout.currentBranch
|
||||
: null;
|
||||
const isPaseoOwnedWorktree = checkout.isGit && checkout.isPaseoOwnedWorktree;
|
||||
const mainRepoRoot = checkout.isGit ? checkout.mainRepoRoot : null;
|
||||
const derived = checkoutDerivedWorkspaceFields(workspace, checkout);
|
||||
if (
|
||||
workspace.kind === kind &&
|
||||
workspace.branch === branch &&
|
||||
workspace.isPaseoOwnedWorktree === isPaseoOwnedWorktree &&
|
||||
workspace.mainRepoRoot === mainRepoRoot
|
||||
workspace.kind === derived.kind &&
|
||||
workspace.branch === derived.branch &&
|
||||
workspace.displayName === derived.displayName &&
|
||||
workspace.isPaseoOwnedWorktree === derived.isPaseoOwnedWorktree &&
|
||||
workspace.mainRepoRoot === derived.mainRepoRoot
|
||||
) {
|
||||
return workspace;
|
||||
}
|
||||
const next = {
|
||||
...workspace,
|
||||
kind,
|
||||
branch,
|
||||
isPaseoOwnedWorktree,
|
||||
mainRepoRoot,
|
||||
...derived,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await workspaceRegistry.upsert(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function checkoutDerivedWorkspaceFields(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
checkout: Awaited<ReturnType<WorkspaceGitService["getCheckout"]>>,
|
||||
): Pick<
|
||||
PersistedWorkspaceRecord,
|
||||
"kind" | "branch" | "displayName" | "isPaseoOwnedWorktree" | "mainRepoRoot"
|
||||
> {
|
||||
return {
|
||||
kind: deriveWorkspaceKind(checkout),
|
||||
branch:
|
||||
checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD"
|
||||
? checkout.currentBranch
|
||||
: null,
|
||||
displayName: deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout }),
|
||||
isPaseoOwnedWorktree: checkout.isGit && checkout.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: checkout.isGit ? checkout.mainRepoRoot : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshProjectKind(
|
||||
project: PersistedProjectRecord,
|
||||
workspaceCwd: string,
|
||||
|
||||
@@ -124,13 +124,12 @@ export async function archiveCommand(
|
||||
): Promise<ArchiveCommandResult> {
|
||||
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
|
||||
const scope = input.scope ?? "workspace";
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
|
||||
if (scope === "worktree") {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
|
||||
if (!ownership.allowed) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -170,7 +169,8 @@ export async function archiveCommand(
|
||||
|
||||
const result = await archiveByScope(dependencies, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: resolvedTarget.repoRoot,
|
||||
repoRoot: resolvedTarget.repoRoot ?? ownership.repoRoot ?? null,
|
||||
repoWorktreesRoot: ownership.worktreeRoot,
|
||||
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
requestId: input.requestId,
|
||||
});
|
||||
|
||||
@@ -838,9 +838,19 @@ export function mapWorkspaceCwdToWorktree(input: {
|
||||
throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`);
|
||||
}
|
||||
|
||||
const mappedCwd = resolve(input.targetWorktreePath, relativeWorkspaceCwd);
|
||||
return mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd,
|
||||
targetWorktreePath: input.targetWorktreePath,
|
||||
});
|
||||
}
|
||||
|
||||
export function mapWorkspaceRelativeCwdToWorktree(input: {
|
||||
relativeWorkspaceCwd: string;
|
||||
targetWorktreePath: string;
|
||||
}): string {
|
||||
const mappedCwd = resolve(input.targetWorktreePath, input.relativeWorkspaceCwd);
|
||||
if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) {
|
||||
throw new Error(`Workspace cwd escapes its target worktree: ${input.workspaceCwd}`);
|
||||
throw new Error(`Workspace cwd escapes its target worktree: ${input.relativeWorkspaceCwd}`);
|
||||
}
|
||||
return mappedCwd;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user