mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Change a worktree workspace base from Changes (#8642)
Change a worktree workspace's base from the Changes pane. The choice persists and diffs, counts, commits and Git actions follow it. Supersedes #6872. Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
This commit is contained in:
co-authored by
Tianyu Yang
parent
e9f897a66a
commit
3ed57e9dd7
@@ -216,6 +216,7 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.label.list.request": "workspace.read",
|
||||
"workspace.label.update.request": "workspace.manage",
|
||||
"workspace.pin.set.request": "workspace.manage",
|
||||
"workspace.base_branch.set.request": "workspace.write",
|
||||
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
|
||||
"workspace.script.list.request": "workspace.read",
|
||||
@@ -458,6 +459,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.label.update": "workspace.read",
|
||||
"workspace.label.update.response": "workspace.manage",
|
||||
"workspace.pin.set.response": "workspace.manage",
|
||||
"workspace.base_branch.set.response": "workspace.write",
|
||||
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
|
||||
"workspace.script.list.response": "workspace.read",
|
||||
|
||||
@@ -195,6 +195,7 @@ export class CheckoutDiffManager {
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree,
|
||||
baseRef: snapshot.git.baseRef,
|
||||
baseRefExact: snapshot.git.baseRefExact,
|
||||
aheadBehind: snapshot.git.aheadBehind,
|
||||
aheadOfOrigin: snapshot.git.aheadOfOrigin,
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin,
|
||||
|
||||
@@ -61,6 +61,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
||||
currentBranch: snapshot.git.currentBranch ?? null,
|
||||
isDirty: snapshot.git.isDirty,
|
||||
baseRef: snapshot.git.baseRef,
|
||||
baseRefExact: snapshot.git.baseRefExact,
|
||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||
upstreamRef: snapshot.git.upstreamRef ?? null,
|
||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||
@@ -81,6 +82,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
||||
currentBranch: snapshot.git.currentBranch ?? null,
|
||||
isDirty: snapshot.git.isDirty,
|
||||
baseRef: snapshot.git.baseRef ?? null,
|
||||
baseRefExact: snapshot.git.baseRefExact,
|
||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||
upstreamRef: snapshot.git.upstreamRef ?? null,
|
||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("Git metadata event rules", () => {
|
||||
["worktree", "HEAD", { kind: "owner", refreshBase: true }],
|
||||
["worktree", "config.worktree", { kind: "owner", refreshBase: true }],
|
||||
["worktree", "paseo/worktree.json", { kind: "owner", refreshBase: true }],
|
||||
["common", "paseo/worktree.json", { kind: "owner", refreshBase: true }],
|
||||
["worktree", "COMMIT_EDITMSG", { kind: "ignore" }],
|
||||
["worktree", "ORIG_HEAD", { kind: "ignore" }],
|
||||
["common", "index", { kind: "owner", refreshBase: false }],
|
||||
|
||||
@@ -44,7 +44,7 @@ export const GIT_METADATA_EVENT_RULES = [
|
||||
},
|
||||
{
|
||||
id: "paseo-worktree-metadata",
|
||||
scope: "worktree",
|
||||
scope: "both",
|
||||
match: "exact",
|
||||
path: "paseo/worktree.json",
|
||||
route: "owner",
|
||||
|
||||
@@ -3,6 +3,12 @@ import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import {
|
||||
createWorkspaceBaseBranchService,
|
||||
type WorkspaceBaseBranchService,
|
||||
} from "./session/workspace-base-branch/service.js";
|
||||
import type { WorkspaceBaseBranchSetRequest } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { searchTimeline } from "./agent/chat-search/index.js";
|
||||
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
|
||||
@@ -483,6 +489,7 @@ export interface SessionOptions {
|
||||
creationService: Pick<CreationService, "create" | "subscribe">;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceBaseBranch?: WorkspaceBaseBranchService;
|
||||
directorySync?: DirectorySyncService;
|
||||
workspaceLabelService?: WorkspaceLabelService;
|
||||
filesystem?: SessionFileSystem;
|
||||
@@ -779,6 +786,7 @@ export class Session {
|
||||
private readonly gitMutation: GitMutationService;
|
||||
private readonly workspaceProvisioning: WorkspaceProvisioningService;
|
||||
private readonly workspaceRecovery: WorkspaceRecoveryService;
|
||||
private workspaceBaseBranch: WorkspaceBaseBranchService | undefined;
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly pushNotifications: PushNotifications;
|
||||
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
||||
@@ -962,12 +970,22 @@ export class Session {
|
||||
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
||||
this.workspaceGitService = workspaceGitService;
|
||||
this.workspaceRuntimeStore = workspaceRuntimeStore;
|
||||
this.workspaceBaseBranch =
|
||||
options.workspaceBaseBranch ??
|
||||
createWorkspaceBaseBranchService({
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
|
||||
this.gitMutation = createGitMutationService({
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock,
|
||||
lifecycle: this.pluginRuntime,
|
||||
serverId,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
@@ -3213,6 +3231,8 @@ export class Session {
|
||||
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
|
||||
case "workspace.pin.set.request":
|
||||
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
|
||||
case "workspace.base_branch.set.request":
|
||||
return this.handleWorkspaceBaseBranchSetRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -4076,6 +4096,41 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceBaseBranchSetRequest(
|
||||
msg: WorkspaceBaseBranchSetRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
const baseRef = await this.workspaceBaseBranch.setBaseBranch(msg);
|
||||
this.emit({
|
||||
type: "workspace.base_branch.set.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
workspaceId: msg.workspaceId,
|
||||
accepted: true,
|
||||
baseRef,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "workspace.base_branch.set.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
workspaceId: msg.workspaceId,
|
||||
accepted: false,
|
||||
baseRef: null,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspacePinSetRequest(
|
||||
workspaceId: string,
|
||||
pinned: boolean,
|
||||
|
||||
@@ -379,13 +379,14 @@ export class CheckoutSession {
|
||||
}
|
||||
|
||||
async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise<void> {
|
||||
const { cwd, query, limit, requestId } = msg;
|
||||
const { cwd, query, limit, refMode, requestId } = msg;
|
||||
|
||||
try {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, {
|
||||
query,
|
||||
limit,
|
||||
refMode,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "branch_suggestions_response",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { resolve } from "node:path";
|
||||
import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js";
|
||||
import {
|
||||
readPaseoWorktreeMetadata,
|
||||
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;
|
||||
}
|
||||
|
||||
export function createWorkspaceBaseBranchService(deps: Dependencies) {
|
||||
const queues = new Map<string, Promise<void>>();
|
||||
|
||||
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) => {
|
||||
release = done;
|
||||
});
|
||||
queues.set(root, queued);
|
||||
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.listActive();
|
||||
const matching = records.filter(
|
||||
(record) =>
|
||||
record.workspaceId === input.workspaceId ||
|
||||
resolve(record.worktreeRoot ?? record.cwd) === root,
|
||||
);
|
||||
await deps.workspaceGitService.getSnapshot(root);
|
||||
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) {
|
||||
await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({
|
||||
...latest,
|
||||
baseBranch: baseRef,
|
||||
updatedAt,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (metadata)
|
||||
await setPaseoWorktreeBaseRef({
|
||||
worktreeRoot: root,
|
||||
baseRef: metadata.baseRef ?? metadata.baseRefName,
|
||||
});
|
||||
for (const record of matching) {
|
||||
await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({
|
||||
...latest,
|
||||
baseBranch: record.baseBranch,
|
||||
updatedAt: record.updatedAt,
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
+27
-15
@@ -20,6 +20,7 @@ import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.j
|
||||
import { deriveProjectKey } from "../../project-key.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import type { UntrustedWorkspaceSource } from "../../workspace-automation-gate.js";
|
||||
import { readPaseoWorktreeMetadata } from "../../../utils/worktree-metadata.js";
|
||||
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
@@ -107,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;
|
||||
|
||||
@@ -253,21 +255,31 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
? 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 }),
|
||||
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(
|
||||
|
||||
@@ -16,6 +16,10 @@ import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js";
|
||||
import { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import {
|
||||
createWorkspaceBaseBranchService,
|
||||
type WorkspaceBaseBranchService,
|
||||
} from "./session/workspace-base-branch/service.js";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { CreationService } from "./creation/index.js";
|
||||
import { MessageReceipts } from "./message-receipts/index.js";
|
||||
@@ -569,6 +573,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly creationService: CreationService;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly workspaceBaseBranch: WorkspaceBaseBranchService;
|
||||
private readonly workspaceLabelService: WorkspaceLabelService | null;
|
||||
private readonly scheduleService: ScheduleService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||
@@ -756,12 +761,18 @@ 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,
|
||||
});
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
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({
|
||||
@@ -1661,6 +1672,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
creationService: this.creationService,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceBaseBranch: this.workspaceBaseBranch,
|
||||
workspaceLabelService: this.workspaceLabelService ?? undefined,
|
||||
directorySync: this.directorySync,
|
||||
scheduleService: this.scheduleService,
|
||||
@@ -2016,6 +2028,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
backgroundWorkspaces: true,
|
||||
// COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03.
|
||||
backgroundAgents: true,
|
||||
workspaceBaseBranch: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
DaemonClient,
|
||||
@@ -277,6 +278,118 @@ test("renaming a workspace updates every subscribed client", 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", "release.txt"], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "release"], { cwd: repo });
|
||||
const created = await ctx.client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repo, worktreeSlug: "base-refresh", baseBranch: "main" },
|
||||
});
|
||||
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 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.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();
|
||||
execFileSync("git", ["branch", "release"], { cwd: repo });
|
||||
const created = await ctx.client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repo, worktreeSlug: "changed-base", baseBranch: "main" },
|
||||
});
|
||||
const workspace = created.workspace;
|
||||
if (!workspace) throw new Error(created.error ?? "Failed to create workspace");
|
||||
await ctx.client.setWorkspaceBaseBranch({
|
||||
workspaceId: workspace.id,
|
||||
baseRef: "refs/heads/release",
|
||||
});
|
||||
const joinedId = await createLocalWorkspace(
|
||||
workspace.workspaceDirectory,
|
||||
"Joined after changing base",
|
||||
);
|
||||
expect((await ctx.client.archiveWorkspace(workspace.id)).error).toBe(null);
|
||||
expect(existsSync(workspace.workspaceDirectory)).toBe(true);
|
||||
expect((await ctx.client.archiveWorkspace(joinedId)).error).toBe(null);
|
||||
expect(existsSync(workspace.workspaceDirectory)).toBe(false);
|
||||
await ctx.client.restoreWorkspace(joinedId);
|
||||
expect((await readPaseoWorktreeMetadata(workspace.workspaceDirectory))?.baseRef).toBe(
|
||||
"refs/heads/release",
|
||||
);
|
||||
expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe(
|
||||
"refs/heads/release",
|
||||
);
|
||||
});
|
||||
|
||||
test("an archived workspace keeps its base when a later workspace reuses its path", async () => {
|
||||
const repo = createGitRepo();
|
||||
execFileSync("git", ["branch", "release"], { cwd: repo });
|
||||
const source = { kind: "worktree", cwd: repo, worktreeSlug: "reused-path", baseBranch: "main" };
|
||||
const first = (await ctx.client.createWorkspace({ source })).workspace;
|
||||
if (!first) throw new Error("Failed to create first workspace");
|
||||
expect((await ctx.client.archiveWorkspace(first.id)).error).toBe(null);
|
||||
expect(existsSync(first.workspaceDirectory)).toBe(false);
|
||||
const second = (await ctx.client.createWorkspace({ source })).workspace;
|
||||
if (!second) throw new Error("Failed to create second workspace");
|
||||
expect(second.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||
await ctx.client.setWorkspaceBaseBranch({
|
||||
workspaceId: second.id,
|
||||
baseRef: "refs/heads/release",
|
||||
});
|
||||
expect((await ctx.client.archiveWorkspace(second.id)).error).toBe(null);
|
||||
await ctx.client.restoreWorkspace(first.id);
|
||||
expect((await readPaseoWorktreeMetadata(first.workspaceDirectory))?.baseRef).toBe(
|
||||
"refs/heads/main",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["worktree", "workspace"] as const)(
|
||||
"%s archive immediately removes the deleted worktree from a warm CLI listing",
|
||||
async (command) => {
|
||||
|
||||
@@ -766,6 +766,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",
|
||||
afterInFlight: 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}`),
|
||||
|
||||
@@ -160,6 +160,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isDirty: boolean | null;
|
||||
baseRef: string | null;
|
||||
baseRefExact?: string | null;
|
||||
aheadBehind: { ahead: number; behind: number } | null;
|
||||
upstreamRef: string | null;
|
||||
aheadOfOrigin: number | null;
|
||||
@@ -296,6 +297,7 @@ export type WorkspaceGitReadOptions =
|
||||
export interface WorkspaceGitBranchSuggestionsOptions {
|
||||
query?: string;
|
||||
limit?: number;
|
||||
refMode?: "exact";
|
||||
}
|
||||
|
||||
export interface WorkspaceGitStashListOptions {
|
||||
@@ -905,7 +907,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const query = options?.query ?? "";
|
||||
const limit = options?.limit;
|
||||
const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
|
||||
const key = JSON.stringify([
|
||||
"branch-suggestions",
|
||||
normalizedCwd,
|
||||
query,
|
||||
limit ?? null,
|
||||
options?.refMode,
|
||||
]);
|
||||
return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () =>
|
||||
this.deps.listBranchSuggestions(normalizedCwd, options),
|
||||
);
|
||||
@@ -3573,6 +3581,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
const cwd = target.cwd;
|
||||
const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target);
|
||||
const previousBaseRef = target.latestFacts?.isGit ? target.latestFacts.resolvedBaseRef : null;
|
||||
const baseContext: CheckoutContext = {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
@@ -3580,6 +3589,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
runGitCommand: runRefreshGitCommand,
|
||||
};
|
||||
const facts = await this.loadCheckoutFacts(target, baseContext);
|
||||
if (facts.isGit && facts.resolvedBaseRef !== previousBaseRef) {
|
||||
this.invalidateCheckoutDiffCache(cwd, "base");
|
||||
this.invalidateCheckoutDiffCache(facts.worktreeRoot, "base");
|
||||
}
|
||||
const context: CheckoutContext = { ...baseContext, facts };
|
||||
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
||||
if (!checkoutStatus.isGit) {
|
||||
@@ -3612,6 +3625,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
? checkoutStatus.isDirty
|
||||
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
|
||||
baseRef: checkoutStatus.baseRef,
|
||||
baseRefExact: checkoutStatus.baseRefExact,
|
||||
aheadBehind: checkoutStatus.aheadBehind,
|
||||
upstreamRef: checkoutStatus.upstreamRef,
|
||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||
|
||||
@@ -108,7 +108,7 @@ export function initialWorkspacePlacement(
|
||||
|
||||
/**
|
||||
* Applies live placement facts without rewriting the workspace's durable name
|
||||
* or its creation-time base branch.
|
||||
* or its selected base branch.
|
||||
*
|
||||
* A null checkout is an absence, not an observation: nothing was read at the
|
||||
* workspace directory, so the recorded placement stands. Only a directory that
|
||||
|
||||
@@ -75,9 +75,9 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
// archive and recovery do not need the directory to still exist in order to
|
||||
// recover placement.
|
||||
worktreeRoot: z.string().nullable().default(null),
|
||||
// Comparison base preserved across worktree deletion and restore. New branch-off
|
||||
// Current base preserved across worktree deletion and restore. New branch-off
|
||||
// workspaces store the resolved ref (e.g. refs/remotes/upstream/main); legacy and
|
||||
// PR checkout records may contain a bare branch name. Ordinary checkouts use null.
|
||||
// PR checkout records may contain a bare branch name. Null means no recorded base.
|
||||
baseBranch: z
|
||||
.string()
|
||||
.nullable()
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getCheckoutStatus,
|
||||
checkoutResolvedBranch,
|
||||
listBranchSuggestions,
|
||||
setCheckoutBaseBranch,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
MergeConflictError,
|
||||
@@ -263,6 +264,56 @@ describe("checkout git utilities", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
branchName: "feature/base",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "base",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
});
|
||||
const original = await readPaseoWorktreeMetadata(worktree.worktreePath);
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
await setCheckoutBaseBranch({
|
||||
cwd: worktree.worktreePath,
|
||||
baseRef: "release",
|
||||
context: { paseoHome },
|
||||
});
|
||||
expect(await readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({
|
||||
...original,
|
||||
baseRefName: "release",
|
||||
baseRef: "refs/heads/release",
|
||||
});
|
||||
expect(await getCheckoutSnapshotFacts(worktree.worktreePath, { paseoHome })).toMatchObject({
|
||||
storedBaseRef: "refs/heads/release",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists exact local and all remote refs separately for the base picker", async () => {
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/origin/release", "HEAD"], { cwd: repoDir });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "HEAD"], { cwd: repoDir });
|
||||
const suggestions = await listBranchSuggestions(repoDir, {
|
||||
refMode: "exact",
|
||||
query: "release",
|
||||
});
|
||||
expect(suggestions.map((entry) => entry.name).sort()).toEqual([
|
||||
"refs/heads/release",
|
||||
"refs/remotes/origin/release",
|
||||
"refs/remotes/upstream/release",
|
||||
]);
|
||||
expect(suggestions.find((entry) => entry.name === "refs/heads/release")).toMatchObject({
|
||||
hasLocal: true,
|
||||
hasRemote: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws NotGitRepoError for non-git directories", async () => {
|
||||
const nonGitDir = join(tempDir, "not-git");
|
||||
mkdirSync(nonGitDir, { recursive: true });
|
||||
@@ -1920,6 +1971,13 @@ const x = 1;
|
||||
expect(status.currentBranch).toBe("feature/plain");
|
||||
});
|
||||
|
||||
it("rejects merging a branch into its own same-named remote base", async () => {
|
||||
execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir });
|
||||
await expect(mergeToBase(repoDir, { baseRef: "refs/remotes/origin/main" })).rejects.toThrow(
|
||||
"Cannot merge a branch into itself",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges the current branch into base from a worktree checkout", async () => {
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
branchName: "main",
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type PaseoWorktreeMetadata,
|
||||
readPaseoWorktreeMetadata,
|
||||
rebindPaseoWorktreeChangeRequestHint,
|
||||
setPaseoWorktreeBaseRef,
|
||||
} from "./worktree-metadata.js";
|
||||
const READ_ONLY_GIT_ENV = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
@@ -323,15 +324,30 @@ function sortBranchSuggestions(
|
||||
|
||||
export async function listBranchSuggestions(
|
||||
cwd: string,
|
||||
options?: { query?: string; limit?: number },
|
||||
options: { query?: string; limit?: number; refMode?: "exact" } = {},
|
||||
): Promise<BranchSuggestion[]> {
|
||||
await requireGitRepo(cwd);
|
||||
|
||||
const requestedLimit = options?.limit ?? 50;
|
||||
const requestedLimit = options.limit ?? 50;
|
||||
const limit = Math.max(1, Math.min(200, requestedLimit));
|
||||
const rawQuery = options?.query?.trim().toLowerCase() ?? "";
|
||||
const rawQuery = options.query?.trim().toLowerCase() ?? "";
|
||||
const query = normalizeBranchSuggestionName(rawQuery) ?? rawQuery;
|
||||
|
||||
if (options.refMode === "exact") {
|
||||
const refs = await listGitRefs(cwd, "refs");
|
||||
const branches = refs.filter((ref) => {
|
||||
const isBranch = ref.name.startsWith("refs/heads/") || ref.name.startsWith("refs/remotes/");
|
||||
return isBranch && !ref.name.endsWith("/HEAD") && ref.name.toLowerCase().includes(rawQuery);
|
||||
});
|
||||
branches.sort((a, b) => b.committerDate - a.committerDate || a.name.localeCompare(b.name));
|
||||
return branches.slice(0, limit).map((ref) => ({
|
||||
name: ref.name,
|
||||
committerDate: ref.committerDate,
|
||||
hasLocal: ref.name.startsWith("refs/heads/"),
|
||||
hasRemote: ref.name.startsWith("refs/remotes/"),
|
||||
}));
|
||||
}
|
||||
|
||||
const [localRefs, remoteRefs] = await Promise.all([
|
||||
listGitRefs(cwd, "refs/heads"),
|
||||
listGitRefs(cwd, "refs/remotes/origin"),
|
||||
@@ -889,6 +905,7 @@ export interface CheckoutStatusGitNonPaseo {
|
||||
currentBranch: string | null;
|
||||
isDirty: boolean;
|
||||
baseRef: string | null;
|
||||
baseRefExact?: string | null;
|
||||
aheadBehind: AheadBehind | null;
|
||||
// Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the
|
||||
// branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are
|
||||
@@ -908,6 +925,7 @@ export interface CheckoutStatusGitPaseo {
|
||||
currentBranch: string | null;
|
||||
isDirty: boolean;
|
||||
baseRef: string;
|
||||
baseRefExact?: string | null;
|
||||
aheadBehind: AheadBehind | null;
|
||||
upstreamRef: string | null;
|
||||
aheadOfOrigin: number | null;
|
||||
@@ -1308,12 +1326,36 @@ async function getStoredBaseRefForCwd(
|
||||
if (context?.facts?.isGit) {
|
||||
return context.facts.storedBaseRef;
|
||||
}
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context });
|
||||
if (!paseoWorktree.isPaseoOwnedWorktree) {
|
||||
return null;
|
||||
}
|
||||
const ownership = await getPaseoWorktreeForCwd(cwd, { context });
|
||||
return ownership.isPaseoOwnedWorktree ? readPaseoWorktreeBaseRef(ownership.worktreeRoot) : null;
|
||||
}
|
||||
|
||||
return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot);
|
||||
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
|
||||
? [requested]
|
||||
: [`refs/heads/${requested}`, `refs/remotes/${requested}`, `refs/remotes/origin/${requested}`];
|
||||
for (const candidate of candidates) {
|
||||
if (!isQualifiedBranchRef(candidate) || candidate.endsWith("/HEAD")) continue;
|
||||
const valid = await runGitCommand(["check-ref-format", candidate], {
|
||||
cwd: input.cwd,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
if (valid.exitCode !== 0 || !(await doesGitRefExist(input.cwd, candidate))) continue;
|
||||
const worktreeRoot = await getWorktreeRoot(input.cwd);
|
||||
if (!worktreeRoot) throw new NotGitRepoError(input.cwd);
|
||||
await setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate });
|
||||
return candidate;
|
||||
}
|
||||
throw new Error(`Base branch not found: ${requested}`);
|
||||
}
|
||||
|
||||
async function getResolvedBaseRefForCwd(
|
||||
@@ -1786,7 +1828,11 @@ async function getAheadBehind(
|
||||
context?: CheckoutContext,
|
||||
): Promise<AheadBehind | null> {
|
||||
const normalizedBaseRef = branchNameFromRef(baseRef);
|
||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||
if (
|
||||
!normalizedBaseRef ||
|
||||
!currentBranch ||
|
||||
(!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const comparisonBaseRef =
|
||||
@@ -2128,7 +2174,8 @@ export async function getCheckoutSnapshotFacts(
|
||||
if (
|
||||
resolvedBaseRef &&
|
||||
inspected.currentBranch &&
|
||||
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch
|
||||
(isQualifiedBranchRef(resolvedBaseRef) ||
|
||||
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch)
|
||||
) {
|
||||
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
|
||||
() => null,
|
||||
@@ -2373,6 +2420,7 @@ export async function getCheckoutStatus(
|
||||
currentBranch,
|
||||
isDirty,
|
||||
baseRef: displayBaseRef ?? baseRef,
|
||||
baseRefExact: baseRef,
|
||||
aheadBehind,
|
||||
upstreamRef,
|
||||
aheadOfOrigin,
|
||||
@@ -2391,6 +2439,7 @@ export async function getCheckoutStatus(
|
||||
currentBranch,
|
||||
isDirty,
|
||||
baseRef: displayBaseRef,
|
||||
baseRefExact: baseRef,
|
||||
aheadBehind,
|
||||
upstreamRef,
|
||||
aheadOfOrigin,
|
||||
@@ -2605,7 +2654,10 @@ async function tryResolveCheckoutCommitsBaseRef(
|
||||
return null;
|
||||
}
|
||||
const normalizedBaseRef = branchNameFromRef(baseRef);
|
||||
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
||||
if (
|
||||
!normalizedBaseRef ||
|
||||
(!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
@@ -3890,7 +3942,7 @@ export async function mergeToBase(
|
||||
const normalizedBaseRef = resolveMergeTargetBranch(baseRef);
|
||||
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
|
||||
if (normalizedBaseRef === currentBranch) {
|
||||
return currentWorktreeRoot;
|
||||
throw new Error("Cannot merge a branch into itself");
|
||||
}
|
||||
|
||||
const baseWorktree = await getWorktreePathForBranch(cwd, normalizedBaseRef);
|
||||
|
||||
@@ -14,7 +14,7 @@ const ChangeRequestLookupTargetSchema = z.object({
|
||||
localBranchName: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
// baseRefName is the display name; baseRef is the exact ref the worktree was cut from
|
||||
// baseRefName is the display name; baseRef is the checkout's current exact base
|
||||
// ("refs/remotes/upstream/main"). baseRef is optional because worktrees written before it
|
||||
// existed only have the name — there are no migrations, so readers fall back.
|
||||
const PaseoWorktreeMetadataV1Schema = z.object({
|
||||
@@ -249,6 +249,21 @@ export async function writePaseoWorktreeMetadata(
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...current,
|
||||
baseRefName: branchNameFromRef(input.baseRef),
|
||||
baseRef: input.baseRef,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { worktreePort: number },
|
||||
|
||||
Reference in New Issue
Block a user