mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
Shape base picker around managed worktrees
Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
This commit is contained in:
co-authored by
Tianyu Yang
parent
6bf012217a
commit
fbcfa86ee0
@@ -963,7 +963,14 @@ export class Session {
|
||||
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
||||
this.workspaceGitService = workspaceGitService;
|
||||
this.workspaceRuntimeStore = workspaceRuntimeStore;
|
||||
this.workspaceBaseBranch = options.workspaceBaseBranch;
|
||||
this.workspaceBaseBranch =
|
||||
options.workspaceBaseBranch ??
|
||||
createWorkspaceBaseBranchService({
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
|
||||
this.gitMutation = createGitMutationService({
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
@@ -971,6 +978,7 @@ export class Session {
|
||||
});
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock,
|
||||
lifecycle: this.pluginRuntime,
|
||||
serverId,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
@@ -4075,6 +4083,8 @@ export class Session {
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeMetadataFile,
|
||||
setPaseoWorktreeBaseRef,
|
||||
} from "../../../utils/worktree-metadata.js";
|
||||
import type { WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
interface Dependencies {
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
}
|
||||
@@ -17,12 +17,8 @@ interface Dependencies {
|
||||
export function createWorkspaceBaseBranchService(deps: Dependencies) {
|
||||
const queues = new Map<string, Promise<void>>();
|
||||
|
||||
async function setBaseBranch(input: { workspaceId: string; baseRef: string }): Promise<string> {
|
||||
const workspace = await deps.workspaceRegistry.get(input.workspaceId);
|
||||
if (!workspace || workspace.archivedAt) throw new Error("Workspace is unavailable");
|
||||
const facts = await getCheckoutSnapshotFacts(workspace.cwd);
|
||||
if (!facts.isGit) throw new Error("Workspace is not a Git checkout");
|
||||
const root = resolve(facts.worktreeRoot);
|
||||
async function withCheckoutLock<T>(root: string, operation: () => Promise<T>): Promise<T> {
|
||||
root = resolve(root);
|
||||
const previous = queues.get(root);
|
||||
let release!: () => void;
|
||||
const queued = new Promise<void>((done) => {
|
||||
@@ -32,6 +28,25 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) {
|
||||
await previous;
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
if (queues.get(root) === queued) queues.delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
async function setBaseBranch(input: { workspaceId: string; baseRef: string }): Promise<string> {
|
||||
const workspace = await deps.workspaceRegistry.get(input.workspaceId);
|
||||
if (!workspace || workspace.archivedAt) throw new Error("Workspace is unavailable");
|
||||
const facts = await getCheckoutSnapshotFacts(workspace.cwd, {
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
if (!facts.isGit) throw new Error("Workspace is not a Git checkout");
|
||||
if (!facts.paseoWorktree.isPaseoOwnedWorktree)
|
||||
throw new Error("Workspace is not a Paseo worktree");
|
||||
const root = resolve(facts.worktreeRoot);
|
||||
return withCheckoutLock(root, async () => {
|
||||
const current = await deps.workspaceRegistry.get(input.workspaceId);
|
||||
if (!current || current.archivedAt) throw new Error("Workspace is unavailable");
|
||||
const records = await deps.workspaceRegistry.list();
|
||||
@@ -41,8 +56,12 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) {
|
||||
resolve(record.worktreeRoot ?? record.cwd) === root,
|
||||
);
|
||||
await deps.workspaceGitService.getSnapshot(root);
|
||||
const metadata = readPaseoWorktreeMetadata(root);
|
||||
const baseRef = await setCheckoutBaseBranch({ cwd: root, baseRef: input.baseRef });
|
||||
const metadata = await readPaseoWorktreeMetadata(root);
|
||||
const baseRef = await setCheckoutBaseBranch({
|
||||
cwd: root,
|
||||
baseRef: input.baseRef,
|
||||
context: { paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot },
|
||||
});
|
||||
const updatedAt = new Date().toISOString();
|
||||
try {
|
||||
for (const record of matching) {
|
||||
@@ -53,8 +72,11 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) {
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (metadata) writePaseoWorktreeMetadataFile(root, metadata);
|
||||
else unlinkSync(getPaseoWorktreeMetadataPath(root));
|
||||
if (metadata)
|
||||
await setPaseoWorktreeBaseRef({
|
||||
worktreeRoot: root,
|
||||
baseRef: metadata.baseRef ?? metadata.baseRefName,
|
||||
});
|
||||
for (const record of matching) {
|
||||
await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({
|
||||
...latest,
|
||||
@@ -67,18 +89,16 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) {
|
||||
for (const cwd of new Set(matching.map((record) => record.cwd))) {
|
||||
await deps.workspaceGitService.getSnapshot(cwd, {
|
||||
force: true,
|
||||
afterInFlight: true,
|
||||
includeForge: false,
|
||||
reason: "base-branch-change",
|
||||
});
|
||||
}
|
||||
return baseRef;
|
||||
} finally {
|
||||
release();
|
||||
if (queues.get(root) === queued) queues.delete(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { setBaseBranch };
|
||||
return { setBaseBranch, withCheckoutLock };
|
||||
}
|
||||
|
||||
export type WorkspaceBaseBranchService = ReturnType<typeof createWorkspaceBaseBranchService>;
|
||||
|
||||
+61
@@ -5,6 +5,11 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getCheckoutStatus } from "../../../utils/checkout-git.js";
|
||||
import {
|
||||
setPaseoWorktreeBaseRef,
|
||||
writePaseoWorktreeMetadata,
|
||||
} from "../../../utils/worktree-metadata.js";
|
||||
import { createWorkspaceBaseBranchService } from "../workspace-base-branch/service.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import {
|
||||
createNoGitWorkspaceRuntimeSnapshot,
|
||||
@@ -125,6 +130,62 @@ afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a workspace created during a base change records the new base", async () => {
|
||||
const cwd = path.join(tmpDir, "worktree");
|
||||
mkdirSync(path.join(cwd, ".git"), { recursive: true });
|
||||
await writePaseoWorktreeMetadata(cwd, { baseRefName: "main", baseRef: "refs/heads/main" });
|
||||
const git = createNoopWorkspaceGitService({
|
||||
getCheckout: async () => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "feature",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: cwd,
|
||||
}),
|
||||
});
|
||||
const bases = createWorkspaceBaseBranchService({
|
||||
paseoHome: tmpDir,
|
||||
workspaceRegistry,
|
||||
workspaceGitService: git,
|
||||
});
|
||||
let release!: () => void;
|
||||
let entered!: () => void;
|
||||
let attempted!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const locked = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
attempted = resolve;
|
||||
});
|
||||
const change = bases.withCheckoutLock(cwd, async () => {
|
||||
entered();
|
||||
await gate;
|
||||
await setPaseoWorktreeBaseRef({ worktreeRoot: cwd, baseRef: "refs/heads/release" });
|
||||
});
|
||||
await locked;
|
||||
const creator = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: git,
|
||||
isDirectory,
|
||||
logger,
|
||||
withCheckoutLock: (root, operation) => {
|
||||
attempted();
|
||||
return bases.withCheckoutLock(root, operation);
|
||||
},
|
||||
});
|
||||
const created = creator.createWorkspaceForDirectory(cwd);
|
||||
await waiting;
|
||||
release();
|
||||
await change;
|
||||
expect((await created).baseBranch).toBe("refs/heads/release");
|
||||
});
|
||||
|
||||
test("fresh git repo creates a workspace at the canonical worktree root", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
|
||||
+26
-19
@@ -108,6 +108,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
isDirectory: (path: string) => Promise<boolean>;
|
||||
logger: Logger;
|
||||
lifecycle?: PluginLifecycle;
|
||||
withCheckoutLock?: <T>(root: string, operation: () => Promise<T>) => Promise<T>;
|
||||
}): WorkspaceProvisioningService {
|
||||
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
|
||||
|
||||
@@ -249,29 +250,35 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const baseMetadata = checkout.isGit
|
||||
? readPaseoWorktreeMetadata(checkout.worktreeRoot ?? normalizedCwd)
|
||||
: null;
|
||||
const project = projectId
|
||||
? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout)
|
||||
: // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15.
|
||||
await findOrCreateProjectForDirectory(normalizedCwd);
|
||||
const timestamp = new Date().toISOString();
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: context?.workspaceId ?? generateWorkspaceId(),
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
baseBranch: baseMetadata?.baseRef ?? baseMetadata?.baseRefName ?? null,
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
background: await resolveBackground(context),
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace, {
|
||||
expectsInitialAgent: context?.expectsInitialAgent,
|
||||
});
|
||||
emitWorkspaceCreated(workspace);
|
||||
return workspace;
|
||||
const create = async () => {
|
||||
const baseMetadata =
|
||||
checkout.isGit && checkout.isPaseoOwnedWorktree
|
||||
? await readPaseoWorktreeMetadata(checkout.worktreeRoot ?? normalizedCwd)
|
||||
: null;
|
||||
const timestamp = new Date().toISOString();
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: context?.workspaceId ?? generateWorkspaceId(),
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
baseBranch: baseMetadata?.baseRef ?? baseMetadata?.baseRefName ?? null,
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
background: await resolveBackground(context),
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace, {
|
||||
expectsInitialAgent: context?.expectsInitialAgent,
|
||||
});
|
||||
emitWorkspaceCreated(workspace);
|
||||
return workspace;
|
||||
};
|
||||
return deps.withCheckoutLock && checkout.isGit && checkout.isPaseoOwnedWorktree
|
||||
? deps.withCheckoutLock(checkout.worktreeRoot ?? normalizedCwd, create)
|
||||
: create();
|
||||
}
|
||||
|
||||
async function createWorkspaceForWorktree(
|
||||
|
||||
@@ -760,7 +760,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.checkoutDiffManager = requiredServices.checkoutDiffManager;
|
||||
this.github = github ?? createGitHubService();
|
||||
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
|
||||
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
||||
this.workspaceBaseBranch = createWorkspaceBaseBranchService({
|
||||
paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
@@ -769,7 +772,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.paseoHome = paseoHome;
|
||||
this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() });
|
||||
this.passwordHash = auth?.password;
|
||||
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
||||
this.daemonConfigStore = daemonConfigStore;
|
||||
this.mcpBaseUrl = mcpBaseUrl;
|
||||
this.assignOptionalServices({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -278,79 +278,66 @@ test("renaming a workspace updates every subscribed client", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("changing a checkout base updates subscribed clients and shared workspace recovery records", async () => {
|
||||
test("a local checkout rejects a base change without creating worktree metadata", async () => {
|
||||
const repo = createGitRepo();
|
||||
const id = await createLocalWorkspace(repo, "Local");
|
||||
await expect(
|
||||
ctx.client.setWorkspaceBaseBranch({ workspaceId: id, baseRef: "refs/heads/main" }),
|
||||
).rejects.toThrow("not a Paseo worktree");
|
||||
expect(await readPaseoWorktreeMetadata(repo)).toBeNull();
|
||||
});
|
||||
|
||||
test("changing a worktree base refreshes its diff and persists across restart", async () => {
|
||||
const repo = createGitRepo();
|
||||
execFileSync("git", ["checkout", "-b", "release"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "release.txt"), "release\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["add", "release.txt"], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "release"], { cwd: repo });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "feature.txt"), "feature\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature"], { cwd: repo });
|
||||
const first = await createLocalWorkspace(repo, "First");
|
||||
const second = await createLocalWorkspace(repo, "Second");
|
||||
const observer = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
reconnect: { enabled: false },
|
||||
const created = await ctx.client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repo, worktreeSlug: "base-refresh", baseBranch: "main" },
|
||||
});
|
||||
await observer.connect();
|
||||
const refs: string[] = [];
|
||||
const stop = observer.on("checkout_status_update", (message) => {
|
||||
if (message.payload.cwd === repo && message.payload.baseRefExact)
|
||||
refs.push(message.payload.baseRefExact);
|
||||
const workspace = created.workspace;
|
||||
if (!workspace) throw new Error(created.error ?? "Failed to create workspace");
|
||||
const cwd = workspace.workspaceDirectory;
|
||||
execFileSync("git", ["merge", "--ff-only", "release"], { cwd });
|
||||
expect(
|
||||
(await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path),
|
||||
).toContain("release.txt");
|
||||
await ctx.client.setWorkspaceBaseBranch({
|
||||
workspaceId: workspace.id,
|
||||
baseRef: "refs/heads/release",
|
||||
});
|
||||
expect(
|
||||
(await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path),
|
||||
).not.toContain("release.txt");
|
||||
await expect(
|
||||
ctx.client.setWorkspaceBaseBranch({ workspaceId: workspace.id, baseRef: "refs/heads/missing" }),
|
||||
).rejects.toThrow("Base branch not found");
|
||||
execFileSync("git", ["update-ref", "refs/remotes/origin/vanished", "release"], { cwd });
|
||||
await ctx.client.setWorkspaceBaseBranch({
|
||||
workspaceId: workspace.id,
|
||||
baseRef: "refs/remotes/origin/vanished",
|
||||
});
|
||||
execFileSync("git", ["update-ref", "-d", "refs/remotes/origin/vanished"], { cwd });
|
||||
await ctx.client.setWorkspaceBaseBranch({
|
||||
workspaceId: workspace.id,
|
||||
baseRef: "refs/heads/main",
|
||||
});
|
||||
expect(
|
||||
(await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path),
|
||||
).toContain("release.txt");
|
||||
await ctx.client.setWorkspaceBaseBranch({
|
||||
workspaceId: workspace.id,
|
||||
baseRef: "refs/heads/release",
|
||||
});
|
||||
const events = observer.observeEvents(["checkout_status_update"]);
|
||||
try {
|
||||
await events.ready;
|
||||
await observer.fetchWorkspaces({ subscribe: {} });
|
||||
expect(ctx.client.getLastServerInfoMessage()?.features?.workspaceBaseBranch).toBe(true);
|
||||
await ctx.client.getCheckoutStatus(repo);
|
||||
expect(
|
||||
(await ctx.client.getCheckoutDiff(repo, { mode: "base" })).files.map((file) => file.path),
|
||||
).toContain("release.txt");
|
||||
await ctx.client.setWorkspaceBaseBranch({ workspaceId: first, baseRef: "refs/heads/release" });
|
||||
await expect.poll(() => refs).toContain("refs/heads/release");
|
||||
const diff = await observer.getCheckoutDiff(repo, { mode: "base" });
|
||||
expect(diff.files.map((file) => file.path)).not.toContain("release.txt");
|
||||
expect(diff.files.map((file) => file.path)).toContain("feature.txt");
|
||||
expect((await observer.listCheckoutCommits(repo)).baseRef).toBe("refs/heads/release");
|
||||
const records = JSON.parse(
|
||||
readFileSync(path.join(ctx.daemon.paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
);
|
||||
expect(records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ workspaceId: first, baseBranch: "refs/heads/release" }),
|
||||
expect.objectContaining({ workspaceId: second, baseBranch: "refs/heads/release" }),
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
ctx.client.setWorkspaceBaseBranch({ workspaceId: first, baseRef: "refs/heads/missing" }),
|
||||
).rejects.toThrow("Base branch not found");
|
||||
expect((await observer.getCheckoutStatus(repo)).baseRefExact).toBe("refs/heads/release");
|
||||
} finally {
|
||||
stop();
|
||||
await events.release();
|
||||
await observer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a selected checkout base survives a daemon restart", async () => {
|
||||
const repo = createGitRepo();
|
||||
execFileSync("git", ["branch", "release"], { cwd: repo });
|
||||
const id = await createLocalWorkspace(repo, "Restart");
|
||||
await ctx.client.setWorkspaceBaseBranch({ workspaceId: id, baseRef: "refs/heads/release" });
|
||||
const previous = ctx;
|
||||
const paseoHomeRoot = path.dirname(previous.daemon.paseoHome);
|
||||
await previous.client.close();
|
||||
await previous.daemon.daemon.stop();
|
||||
tempRoots.push(previous.daemon.staticDir);
|
||||
ctx = await createDaemonTestContext({ paseoHomeRoot });
|
||||
expect(
|
||||
(await ctx.client.fetchWorkspaces()).entries.some((workspace) => workspace.id === id),
|
||||
).toBe(true);
|
||||
expect((await ctx.client.getCheckoutStatus(repo)).baseRefExact).toBe("refs/heads/release");
|
||||
});
|
||||
expect((await ctx.client.getCheckoutStatus(cwd)).baseRefExact).toBe("refs/heads/release");
|
||||
}, 30_000);
|
||||
|
||||
test("a managed workspace restores its changed base after archival removes the checkout", async () => {
|
||||
const repo = createGitRepo();
|
||||
@@ -373,7 +360,7 @@ test("a managed workspace restores its changed base after archival removes the c
|
||||
expect((await ctx.client.archiveWorkspace(joinedId)).error).toBe(null);
|
||||
expect(existsSync(workspace.workspaceDirectory)).toBe(false);
|
||||
await ctx.client.restoreWorkspace(joinedId);
|
||||
expect(readPaseoWorktreeMetadata(workspace.workspaceDirectory)?.baseRef).toBe(
|
||||
expect((await readPaseoWorktreeMetadata(workspace.workspaceDirectory))?.baseRef).toBe(
|
||||
"refs/heads/release",
|
||||
);
|
||||
expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe(
|
||||
|
||||
@@ -764,6 +764,35 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a refresh requested after a mutation waits for a new snapshot", async () => {
|
||||
const gate = createDeferred<CheckoutStatusGit>();
|
||||
let reads = 0;
|
||||
const service = createService({
|
||||
getCheckoutStatus: (cwd: string) => {
|
||||
reads += 1;
|
||||
return reads === 1
|
||||
? gate.promise
|
||||
: Promise.resolve(createCheckoutStatus(cwd, { currentBranch: "new-base" }));
|
||||
},
|
||||
});
|
||||
const first = service.getSnapshot(REPO_CWD, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "before-mutation",
|
||||
});
|
||||
await vi.waitFor(() => expect(reads).toBe(1));
|
||||
const after = service.getSnapshot(REPO_CWD, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "after-mutation",
|
||||
afterCurrent: true,
|
||||
});
|
||||
gate.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "old-base" }));
|
||||
await first;
|
||||
expect((await after).git.currentBranch).toBe("new-base");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("bounds refresh generations and re-enqueues hot workspace successors fairly", async () => {
|
||||
const hotCwds = Array.from({ length: WORKSPACE_GIT_REFRESH_CONCURRENCY }, (_, index) =>
|
||||
path.resolve(`/tmp/hot-repo-${index}`),
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
getCheckoutStatus,
|
||||
checkoutResolvedBranch,
|
||||
listBranchSuggestions,
|
||||
listCheckoutCommits,
|
||||
setCheckoutBaseBranch,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
@@ -265,54 +264,9 @@ describe("checkout git utilities", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("changing the checkout base persists an exact ref without changing HEAD or dirty files", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "release"], { cwd: repoDir });
|
||||
commitFile(repoDir, "release.txt", "release\n", "release change");
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature change");
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "dirty\n");
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString();
|
||||
|
||||
expect(await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/heads/release" })).toBe(
|
||||
"refs/heads/release",
|
||||
);
|
||||
const localDiff = await getCheckoutDiff(repoDir, { mode: "base" });
|
||||
expect(localDiff.diff).toContain("feature.txt");
|
||||
expect(localDiff.diff).not.toContain("release.txt");
|
||||
expect((await listCheckoutCommits({ cwd: repoDir })).baseRef).toBe("refs/heads/release");
|
||||
expect(await getCheckoutSnapshotFacts(repoDir)).toMatchObject({
|
||||
storedBaseRef: "refs/heads/release",
|
||||
});
|
||||
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/remotes/upstream/release" });
|
||||
expect((await getCheckoutDiff(repoDir, { mode: "base" })).diff).toContain("release.txt");
|
||||
expect((await listCheckoutCommits({ cwd: repoDir })).baseRef).toBe(
|
||||
"refs/remotes/upstream/release",
|
||||
);
|
||||
await expect(
|
||||
getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/heads/release" }),
|
||||
).rejects.toThrow("Base ref mismatch");
|
||||
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString()).toBe(head);
|
||||
expect(readTextFile(join(repoDir, "file.txt"))).toBe("dirty\n");
|
||||
expect(await getCurrentBranch(repoDir)).toBe("feature");
|
||||
});
|
||||
|
||||
it("rejects non-branch refs and missing bases without changing the saved base", async () => {
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" });
|
||||
const metadata = readFileSync(getPaseoWorktreeMetadataPath(repoDir), "utf8");
|
||||
execFileSync("git", ["tag", "release-tag"], { cwd: repoDir });
|
||||
for (const baseRef of [
|
||||
"missing",
|
||||
"HEAD",
|
||||
"refs/tags/release-tag",
|
||||
"main~1",
|
||||
"--help",
|
||||
"refs/heads/main^{commit}",
|
||||
]) {
|
||||
await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef })).rejects.toThrow();
|
||||
expect(readFileSync(getPaseoWorktreeMetadataPath(repoDir), "utf8")).toBe(metadata);
|
||||
}
|
||||
it("rejects changing the base of a local checkout without creating metadata", async () => {
|
||||
await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" })).rejects.toThrow();
|
||||
expect(existsSync(await getPaseoWorktreeMetadataPath(repoDir))).toBe(false);
|
||||
});
|
||||
|
||||
it("changing a managed worktree base preserves its runtime and PR identity", async () => {
|
||||
@@ -324,10 +278,14 @@ describe("checkout git utilities", () => {
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
});
|
||||
const original = readPaseoWorktreeMetadata(worktree.worktreePath);
|
||||
const original = await readPaseoWorktreeMetadata(worktree.worktreePath);
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
await setCheckoutBaseBranch({ cwd: worktree.worktreePath, baseRef: "release" });
|
||||
expect(readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({
|
||||
await setCheckoutBaseBranch({
|
||||
cwd: worktree.worktreePath,
|
||||
baseRef: "release",
|
||||
context: { paseoHome },
|
||||
});
|
||||
expect(await readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({
|
||||
...original,
|
||||
baseRefName: "release",
|
||||
baseRef: "refs/heads/release",
|
||||
@@ -356,45 +314,6 @@ describe("checkout git utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("merges from and into the selected base without changing the repository default branch", async () => {
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature");
|
||||
execFileSync("git", ["checkout", "release"], { cwd: repoDir });
|
||||
commitFile(repoDir, "release.txt", "release\n", "release");
|
||||
execFileSync("git", ["checkout", "feature"], { cwd: repoDir });
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "release" });
|
||||
await mergeFromBase(repoDir);
|
||||
expect(readTextFile(join(repoDir, "release.txt"))).toBe("release\n");
|
||||
const featureHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString();
|
||||
const mainHead = execFileSync("git", ["rev-parse", "main"], { cwd: repoDir }).toString();
|
||||
await mergeToBase(repoDir);
|
||||
expect(execFileSync("git", ["rev-parse", "release"], { cwd: repoDir }).toString()).toBe(
|
||||
featureHead,
|
||||
);
|
||||
expect(execFileSync("git", ["rev-parse", "main"], { cwd: repoDir }).toString()).toBe(mainHead);
|
||||
await expect(mergeFromBase(repoDir, { baseRef: "main" })).rejects.toThrow("Base ref mismatch");
|
||||
});
|
||||
|
||||
it("creates new pull requests targeting the selected base", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature");
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/heads/release" });
|
||||
const targets: string[] = [];
|
||||
const adapter = createGitHubServiceForStatus(null);
|
||||
adapter.createPullRequest = async (input) => {
|
||||
targets.push(input.base);
|
||||
return { url: "https://github.com/acme/repo/pull/1", number: 1 };
|
||||
};
|
||||
await createPullRequest(repoDir, { title: "Feature", body: "Feature" }, adapter);
|
||||
expect(targets).toEqual(["release"]);
|
||||
await expect(
|
||||
createPullRequest(repoDir, { title: "Stale target", body: "", base: "main" }, adapter),
|
||||
).rejects.toThrow("Base ref mismatch");
|
||||
});
|
||||
|
||||
it("throws NotGitRepoError for non-git directories", async () => {
|
||||
const nonGitDir = join(tempDir, "not-git");
|
||||
mkdirSync(nonGitDir, { recursive: true });
|
||||
|
||||
@@ -1326,15 +1326,18 @@ async function getStoredBaseRefForCwd(
|
||||
if (context?.facts?.isGit) {
|
||||
return context.facts.storedBaseRef;
|
||||
}
|
||||
const worktreeRoot = await getWorktreeRoot(cwd, context);
|
||||
return worktreeRoot ? readPaseoWorktreeBaseRef(worktreeRoot) : null;
|
||||
const ownership = await getPaseoWorktreeForCwd(cwd, { context });
|
||||
return ownership.isPaseoOwnedWorktree ? readPaseoWorktreeBaseRef(ownership.worktreeRoot) : null;
|
||||
}
|
||||
|
||||
export async function setCheckoutBaseBranch(input: {
|
||||
cwd: string;
|
||||
baseRef: string;
|
||||
context?: CheckoutContext;
|
||||
}): Promise<string> {
|
||||
await requireGitRepo(input.cwd);
|
||||
const ownership = await getPaseoWorktreeForCwd(input.cwd, { context: input.context });
|
||||
if (!ownership.isPaseoOwnedWorktree) throw new Error("Workspace is not a Paseo worktree");
|
||||
const requested = input.baseRef.trim();
|
||||
const qualified = requested.startsWith("refs/");
|
||||
const candidates = qualified
|
||||
@@ -1349,7 +1352,7 @@ export async function setCheckoutBaseBranch(input: {
|
||||
if (valid.exitCode !== 0 || !(await doesGitRefExist(input.cwd, candidate))) continue;
|
||||
const worktreeRoot = await getWorktreeRoot(input.cwd);
|
||||
if (!worktreeRoot) throw new NotGitRepoError(input.cwd);
|
||||
setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate });
|
||||
await setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate });
|
||||
return candidate;
|
||||
}
|
||||
throw new Error(`Base branch not found: ${requested}`);
|
||||
|
||||
@@ -249,7 +249,10 @@ export async function writePaseoWorktreeMetadata(
|
||||
});
|
||||
}
|
||||
|
||||
export async function setPaseoWorktreeBaseRef(input: { worktreeRoot: string; baseRef: string }): Promise<void> {
|
||||
export async function setPaseoWorktreeBaseRef(input: {
|
||||
worktreeRoot: string;
|
||||
baseRef: string;
|
||||
}): Promise<void> {
|
||||
return mutateMetadata(input.worktreeRoot, async (root) => {
|
||||
const current = await readMetadataFile(root);
|
||||
if (!current) throw new Error("Cannot change base: missing worktree metadata");
|
||||
|
||||
Reference in New Issue
Block a user