mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 19:22:51 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ce4b590a |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -11,7 +11,10 @@ import {
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import {
|
||||
expectAgentAbsentFromWorkspaceLayout,
|
||||
seedForeignPinnedAgentTab,
|
||||
} from "../support/helpers/workspace-layout";
|
||||
import { clickNewChat } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const serverId = getServerId();
|
||||
const firstWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-owner-",
|
||||
title: "Original workspace",
|
||||
});
|
||||
const secondWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-restored-",
|
||||
title: "Restored workspace",
|
||||
});
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: "Original workspace agent",
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: "Restored workspace agent",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await seedForeignPinnedAgentTab(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
});
|
||||
await testInfo.attach("hydrated-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expectAgentAbsentFromWorkspaceLayout(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await testInfo.attach("restored-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
const agents = await firstWorkspace.client.fetchAgents();
|
||||
expect(
|
||||
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
|
||||
).toBe(firstWorkspace.workspaceId);
|
||||
expect(
|
||||
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
|
||||
).toBe(secondWorkspace.workspaceId);
|
||||
await testInfo.attach("original-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await secondWorkspace.cleanup();
|
||||
await firstWorkspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an offline notification on the agent route until the host connects", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
|
||||
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
} from "../../../src/workspace-tabs/model";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function seedForeignPinnedAgentTab(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||
if (raw === null) throw new Error("Workspace layout was not persisted");
|
||||
const stored = z
|
||||
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
|
||||
.parse(JSON.parse(raw));
|
||||
const key = buildWorkspaceTabPersistenceKey(input);
|
||||
const layout = key ? stored.state.layoutByWorkspace[key] : null;
|
||||
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
|
||||
const opened = openTabInLayoutFocused({
|
||||
layout,
|
||||
target: { kind: "agent", agentId: input.agentId },
|
||||
now: 1,
|
||||
placement: { mode: "focused" },
|
||||
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
|
||||
});
|
||||
if (!opened) throw new Error("Failed to seed foreign agent tab");
|
||||
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
|
||||
...stored.state,
|
||||
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
|
||||
pinnedAgentIdsByWorkspace: {
|
||||
...stored.state.pinnedAgentIdsByWorkspace,
|
||||
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
|
||||
},
|
||||
});
|
||||
await page.evaluate(
|
||||
(value) => localStorage.setItem("workspace-layout-state", value),
|
||||
JSON.stringify(stored),
|
||||
);
|
||||
}
|
||||
|
||||
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
|
||||
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||
|
||||
@@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot {
|
||||
terminalsHydrated: boolean;
|
||||
activeAgentIds: Iterable<string>;
|
||||
autoOpenAgentIds: Iterable<string>;
|
||||
foreignAgentIds?: Iterable<string>;
|
||||
knownTerminalIds?: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingTerminalCreate?: boolean;
|
||||
@@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs(
|
||||
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
|
||||
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
|
||||
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
|
||||
// An explicit open owns the tab until the agent joins the active directory.
|
||||
// From then on it follows the normal server archive lifecycle. Detail/cache
|
||||
// hydration never decides whether the user's target is allowed to stay open.
|
||||
const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []);
|
||||
// Explicit opens survive absent directory entries, but not known foreign ownership.
|
||||
// Once an agent joins this workspace's active directory, normal archive rules apply.
|
||||
const pinnedAgentIds = new Set(
|
||||
[...(state.pinnedAgentIds ?? [])].filter(
|
||||
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
|
||||
),
|
||||
[...(state.pinnedAgentIds ?? [])].filter((agentId) => {
|
||||
if (!snapshot.agentsHydrated) {
|
||||
return true;
|
||||
}
|
||||
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
|
||||
}),
|
||||
);
|
||||
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
|
||||
const knownTerminalIds = snapshot.knownTerminalIds
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
createWorkspaceAgentVisibilitySelector,
|
||||
@@ -63,6 +64,118 @@ function makeAgent(input: {
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
|
||||
describe("workspace agent visibility", () => {
|
||||
it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: async (key: string) => values.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
const original = createWorkspaceLayoutStore(undefined, storage);
|
||||
await original.persist.rehydrate();
|
||||
const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" });
|
||||
for (const workspaceId of ["ws-a", "ws-b"]) {
|
||||
original.getState().openTab({
|
||||
workspaceKey: `srv:${workspaceId}`,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
}
|
||||
const restored = createWorkspaceLayoutStore(undefined, storage);
|
||||
await restored.persist.rehydrate();
|
||||
const sessionAgents = new Map([[agent.id, agent]]);
|
||||
const beforeHydration = buildWorkspaceTabSnapshot({
|
||||
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }),
|
||||
agentsHydrated: false,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
restored.getState().reconcileTabs("srv:ws-b", beforeHydration);
|
||||
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id]));
|
||||
for (const workspaceId of ["ws-a", "ws-b"]) {
|
||||
restored.getState().reconcileTabs(
|
||||
`srv:${workspaceId}`,
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }),
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
hasActivePendingDraftCreate: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
restored
|
||||
.getState()
|
||||
.getWorkspaceTabs("srv:ws-a")
|
||||
.filter((tab) => tab.target.kind === "agent")
|
||||
.map((tab) => tab.target),
|
||||
).toEqual([{ kind: "agent", agentId: agent.id }]);
|
||||
expect(
|
||||
restored
|
||||
.getState()
|
||||
.getWorkspaceTabs("srv:ws-b")
|
||||
.filter((tab) => tab.target.kind === "agent"),
|
||||
).toEqual([]);
|
||||
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses directory ownership before historical details when identifying foreign agents", () => {
|
||||
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
|
||||
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
|
||||
const unknown = makeAgent({ id: "unknown", cwd: "/repo" });
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sessionAgents: new Map([
|
||||
[own.id, own],
|
||||
[foreign.id, foreign],
|
||||
[unknown.id, unknown],
|
||||
]),
|
||||
agentDetails: new Map([
|
||||
[own.id, { ...own, workspaceId: "ws-2" }],
|
||||
[foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }],
|
||||
[unknown.id, { ...unknown, workspaceId: "ws-2" }],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set([own.id]));
|
||||
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
|
||||
});
|
||||
|
||||
it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => {
|
||||
const archived = makeAgent({
|
||||
id: "archived",
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" };
|
||||
const unknown = { ...archived, id: "unknown", workspaceId: undefined };
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sessionAgents: new Map(),
|
||||
agentDetails: new Map([
|
||||
[archived.id, archived],
|
||||
[foreign.id, foreign],
|
||||
[unknown.id, unknown],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set());
|
||||
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
|
||||
});
|
||||
|
||||
it("keeps subagents active while excluding them from auto-open", () => {
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
@@ -86,6 +199,7 @@ describe("workspace agent visibility", () => {
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
|
||||
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
|
||||
expect(result.foreignAgentIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("excludes archived subagents from active and auto-open", () => {
|
||||
@@ -295,6 +409,7 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"]));
|
||||
});
|
||||
|
||||
it("excludes agents without a workspaceId", () => {
|
||||
@@ -308,12 +423,14 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
expect(result.foreignAgentIds).toEqual(new Set<string>());
|
||||
});
|
||||
|
||||
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
|
||||
const agentVisibility = {
|
||||
activeAgentIds: new Set(["active-agent"]),
|
||||
autoOpenAgentIds: new Set(["root-agent"]),
|
||||
foreignAgentIds: new Set(["foreign-agent"]),
|
||||
};
|
||||
|
||||
expect(
|
||||
@@ -331,6 +448,7 @@ describe("workspace agent visibility", () => {
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
|
||||
foreignAgentIds: agentVisibility.foreignAgentIds,
|
||||
knownTerminalIds: ["terminal-1", "script-terminal"],
|
||||
standaloneTerminalIds: ["terminal-1"],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
@@ -469,6 +587,24 @@ describe("workspace agent visibility", () => {
|
||||
expect(after.activeAgentIds).toEqual(new Set([child.id]));
|
||||
expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
|
||||
});
|
||||
|
||||
it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => {
|
||||
seedSession(new Map([[parent.id, parent]]));
|
||||
const select = createWorkspaceAgentVisibilitySelector({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const before = select(useSessionStore.getState());
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]]));
|
||||
const after = select(useSessionStore.getState());
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.activeAgentIds).toEqual(before.activeAgentIds);
|
||||
expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds);
|
||||
expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceAgentVisibilityEqual", () => {
|
||||
@@ -476,10 +612,12 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set(["c"]),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set(["c"]),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
|
||||
});
|
||||
@@ -488,10 +626,12 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set(["a"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set(["b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
|
||||
});
|
||||
@@ -500,10 +640,12 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["b"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
|
||||
});
|
||||
@@ -512,10 +654,12 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
export interface WorkspaceAgentVisibility {
|
||||
activeAgentIds: Set<string>;
|
||||
autoOpenAgentIds: Set<string>;
|
||||
}
|
||||
|
||||
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
|
||||
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
|
||||
foreignAgentIds: Set<string>;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceAgentVisibility(input: {
|
||||
@@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, agentDetails } = input;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if ((!sessionAgents && !agentDetails) || !workspaceId) {
|
||||
if (!workspaceId) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
const activeAgentIds = new Set<string>();
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const foreignAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (!agentBelongsToWorkspace(agent, workspaceId)) {
|
||||
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
|
||||
if (ownerId !== workspaceId) {
|
||||
if (ownerId) {
|
||||
foreignAgentIds.add(agent.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!agent.archivedAt) {
|
||||
@@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { activeAgentIds, autoOpenAgentIds };
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (sessionAgents?.has(agent.id)) {
|
||||
continue;
|
||||
}
|
||||
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
|
||||
if (ownerId && ownerId !== workspaceId) {
|
||||
foreignAgentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
|
||||
}
|
||||
|
||||
interface AgentMapsSnapshot {
|
||||
@@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: {
|
||||
terminalsHydrated: input.terminalsHydrated,
|
||||
activeAgentIds: input.agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
|
||||
foreignAgentIds: input.agentVisibility.foreignAgentIds,
|
||||
knownTerminalIds: input.knownTerminalIds,
|
||||
standaloneTerminalIds: input.standaloneTerminalIds,
|
||||
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
|
||||
@@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual(
|
||||
return (
|
||||
a === b ||
|
||||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
|
||||
setsEqual(a.foreignAgentIds, b.foreignAgentIds))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { realpath } from "node:fs";
|
||||
import { readlink } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import pLimit from "p-limit";
|
||||
import { dirname, resolve as resolvePath, sep } from "node:path";
|
||||
import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js";
|
||||
|
||||
const nativeRealpath = promisify(realpath.native);
|
||||
const jsRealpath = promisify(realpath);
|
||||
const identityIo = pLimit(2);
|
||||
|
||||
/** A subscription owns its lexical namespace, including the OS's real-root alias. */
|
||||
export class GitObservationPaths {
|
||||
private constructor(
|
||||
private readonly root: string,
|
||||
private readonly aliases: string[],
|
||||
private readonly windows: boolean,
|
||||
) {}
|
||||
|
||||
static async create(root: string): Promise<GitObservationPaths> {
|
||||
const windows = looksLikeDefiniteWindowsPath(root);
|
||||
const normalizePath = (value: string) =>
|
||||
windows
|
||||
? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase()
|
||||
: normalizePathPreservingCase(value, false);
|
||||
const aliases = new Set([normalizePath(root)]);
|
||||
// Sequential: foreign paths must not fan out into the shared libuv pool.
|
||||
for (const resolve of [nativeRealpath, jsRealpath]) {
|
||||
try {
|
||||
aliases.add(normalizePath(await identityIo(() => resolve(root))));
|
||||
} catch {
|
||||
/* Missing roots retain lexical identity. */
|
||||
}
|
||||
}
|
||||
// A link may name /var/... while realpath returns /private/var/... on macOS.
|
||||
// Retain literal targets too, including chains, without following links in callbacks.
|
||||
let current = root;
|
||||
const visited = new Set<string>();
|
||||
for (let remaining = 40; remaining > 0; remaining--) {
|
||||
const alias = normalizePath(current);
|
||||
if (visited.has(alias)) break;
|
||||
visited.add(alias);
|
||||
aliases.add(alias);
|
||||
try {
|
||||
const target = await identityIo(() => readlink(current));
|
||||
current = resolvePath(dirname(current), target);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new GitObservationPaths(root, [...aliases], windows);
|
||||
}
|
||||
|
||||
relative(candidate: string): string | null {
|
||||
const normalized = this.normalizeEventPath(candidate);
|
||||
const comparable = this.windows ? normalized.toLowerCase() : normalized;
|
||||
for (const alias of this.aliases) {
|
||||
if (comparable === alias) return "";
|
||||
const prefix = alias.endsWith("/") ? alias : `${alias}/`;
|
||||
if (comparable.startsWith(prefix)) {
|
||||
// Slice the original spelling, preserving case in ref names on Windows.
|
||||
return normalized.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compile excluded subtrees in every established root namespace. The event callback
|
||||
// only compares strings; neither an ignored root nor an event needs another realpath.
|
||||
createEventFilter(excludedRoots: Iterable<string>): (candidate: string) => boolean {
|
||||
const excluded: string[] = [];
|
||||
for (const root of excludedRoots) {
|
||||
const relative = this.relative(root);
|
||||
if (relative === null) continue;
|
||||
for (const alias of this.aliases) {
|
||||
const subtree = relative ? `${alias}/${relative}` : alias;
|
||||
excluded.push(this.windows ? subtree.toLowerCase() : subtree);
|
||||
}
|
||||
}
|
||||
const prefixes = excluded.map((root) => `${root}/`);
|
||||
return (candidate) => {
|
||||
const normalized = this.normalizeEventPath(candidate);
|
||||
const comparable = this.windows ? normalized.toLowerCase() : normalized;
|
||||
if (this.aliases.includes(comparable)) return false;
|
||||
for (let i = 0; i < excluded.length; i++) {
|
||||
if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEventPath(candidate: string): string {
|
||||
if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/");
|
||||
if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/"))
|
||||
return normalizePathPreservingCase(candidate, false);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
matches = (candidate: string): boolean => this.relative(candidate) === "";
|
||||
|
||||
toWatchPath(candidate: string): string {
|
||||
const relative = this.relative(candidate);
|
||||
if (relative === null) return candidate;
|
||||
return relative ? `${this.root}${sep}${relative}` : this.root;
|
||||
}
|
||||
}
|
||||
@@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
@@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
branchName: "renamed-from-agent-context",
|
||||
});
|
||||
expect(branchAfterFirst).toBe("renamed-from-agent-context");
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
@@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("dazzling-yak");
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
@@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
// baseRefName must be the repo's default branch, not the checked-out branch
|
||||
// itself — otherwise the diff panel compares the branch against itself and
|
||||
// always shows no changes.
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
@@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
try {
|
||||
await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||
@@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
let metadata: Awaited<ReturnType<typeof readPaseoWorktreeMetadata>>;
|
||||
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
|
||||
try {
|
||||
metadata = await readPaseoWorktreeMetadata(options.cwd);
|
||||
metadata = readPaseoWorktreeMetadata(options.cwd);
|
||||
} catch {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
@@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
|
||||
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
|
||||
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
|
||||
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
|
||||
const branchName = await options.generateBranchNameFromContext({
|
||||
cwd: options.cwd,
|
||||
@@ -264,15 +264,15 @@ async function findAvailableBranchName(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
}): Promise<void> {
|
||||
}): void {
|
||||
const { createdWorktree } = options;
|
||||
if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
|
||||
return;
|
||||
}
|
||||
|
||||
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
|
||||
placeholderBranchName: createdWorktree.worktree.branchName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9228,8 +9228,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" });
|
||||
await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
|
||||
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
|
||||
writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
|
||||
placeholderBranchName: "placeholder-branch",
|
||||
});
|
||||
const workspaceCwd = path.join(repoDir, "packages", "app");
|
||||
@@ -9288,7 +9288,6 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
|
||||
firstAgentContext: { prompt: "Fix checkout title" },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd]));
|
||||
|
||||
expect(generateCalls).toBe(1);
|
||||
expect(stored.get(workspace.workspaceId)).toMatchObject({
|
||||
@@ -9300,7 +9299,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("placeholder-branch");
|
||||
expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
|
||||
+3
-3
@@ -347,7 +347,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) {
|
||||
|
||||
test("preserves ordinary checkout behavior when no separate base was recorded", async () => {
|
||||
const fixture = await createBaseRecoveryFixture(null);
|
||||
await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
|
||||
writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
|
||||
await fixture.archiveAndRemove();
|
||||
await fixture.service.restore(fixture.workspace.workspaceId);
|
||||
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull();
|
||||
@@ -430,7 +430,7 @@ test("restores the branch HEAD with a base name when the exact base is missing",
|
||||
});
|
||||
expect(existsSync(fixture.workspace.cwd)).toBe(true);
|
||||
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull();
|
||||
const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd);
|
||||
const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd);
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
expect(metadata?.baseRef).toBeUndefined();
|
||||
expect(
|
||||
@@ -450,7 +450,7 @@ test("restore fetches a deleted local branch from origin and retains its base",
|
||||
await fixture.archiveAndRemove();
|
||||
execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir });
|
||||
await fixture.service.restore(fixture.workspace.workspaceId);
|
||||
expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main");
|
||||
expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main");
|
||||
expect(
|
||||
await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }),
|
||||
).toMatchObject({
|
||||
|
||||
@@ -493,7 +493,6 @@ async function createRestorableWorkspace(baseRef: string) {
|
||||
commitFile(cwd, "feature.txt", "feature change\n");
|
||||
const head = runGit(cwd, "rev-parse", "HEAD");
|
||||
await client.checkoutRefresh(cwd);
|
||||
expect(await client.getCheckoutStatus(cwd)).toMatchObject({ isPaseoOwnedWorktree: true });
|
||||
const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" });
|
||||
expect(before.error).toBeNull();
|
||||
expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]);
|
||||
|
||||
@@ -526,183 +526,3 @@ test("a late Git-ignored tree is pruned while tracked changes still notify consu
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test("Git refresh, command tracing and 10,000 ignored alias events do no synchronous IO", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const source = `
|
||||
import assert from 'node:assert/strict';
|
||||
import { write } from 'node:fs';
|
||||
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { setImmediate as tick } from 'node:timers/promises';
|
||||
import { createHook } from 'node:async_hooks';
|
||||
import pino from 'pino';
|
||||
import { WorkspaceGitServiceImpl } from './packages/server/src/server/workspace-git-service.ts';
|
||||
import { runGitCommand } from './packages/server/src/utils/run-git-command.ts';
|
||||
import { writePaseoWorktreeMetadata } from './packages/server/src/utils/worktree-metadata.ts';
|
||||
const root = await mkdtemp(join(tmpdir(), 'git-async-contract-'));
|
||||
const home = join(root, 'home');
|
||||
const repo = join(root, 'repo');
|
||||
const worktree = join(home, 'worktrees', 'project', 'topic');
|
||||
const alias = join(root, 'alias');
|
||||
const logger = pino({ level: 'trace' }, { write() {} });
|
||||
// Async descriptor writes avoid Windows stderr's synchronous stream API.
|
||||
const marker = (label) => new Promise((resolve, reject) => {
|
||||
write(2, label + '\\n', (error) => error ? reject(error) : resolve());
|
||||
});
|
||||
let callback;
|
||||
let notifications = 0;
|
||||
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: home, deps: {
|
||||
subscribe: async (_root, onChange) => {
|
||||
callback = onChange;
|
||||
return { updateIgnore: async () => {}, unsubscribe: async () => {} };
|
||||
}
|
||||
} });
|
||||
try {
|
||||
await mkdir(repo);
|
||||
await runGitCommand(['init', '-b', 'main'], { cwd: repo });
|
||||
await runGitCommand(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-m', 'base'], { cwd: repo });
|
||||
await mkdir(join(home, 'worktrees', 'project'), { recursive: true });
|
||||
await runGitCommand(['worktree', 'add', '-b', 'topic', worktree], { cwd: repo });
|
||||
await writePaseoWorktreeMetadata(worktree, { baseRefName: 'main' });
|
||||
await writeFile(join(worktree, '.gitignore'), 'ignored/\\n');
|
||||
await mkdir(join(worktree, 'ignored'));
|
||||
await writeFile(join(worktree, 'ignored', 'noise'), 'noise');
|
||||
await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; });
|
||||
const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') }));
|
||||
// A shared CI runner can deschedule the child during a wall-clock sample.
|
||||
// Keep the 20ms gate, but take three independent batches and require one
|
||||
// responsive callback/timer pair. All batches must remain filesystem-free.
|
||||
const samples = [];
|
||||
for (let trial = 0; trial < (process.env.GIT_IO_TRACE ? 1 : 3); trial++) {
|
||||
await tick();
|
||||
let fsCalls = 0;
|
||||
const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } });
|
||||
await marker('FILTER_START');
|
||||
hook.enable();
|
||||
const started = performance.now();
|
||||
const pulse = new Promise((resolve) => setTimeout(() => resolve(performance.now() - started), 0));
|
||||
callback(null, events);
|
||||
const filterMs = performance.now() - started;
|
||||
hook.disable();
|
||||
const loopGapMs = await pulse;
|
||||
await marker('FILTER_END');
|
||||
assert.equal(fsCalls, 0, "filesystem calls");
|
||||
assert.equal(notifications, 0, "ignored event notification");
|
||||
samples.push({ filterMs, loopGapMs, fsCalls });
|
||||
}
|
||||
console.log(JSON.stringify({ ...samples[0], samples }));
|
||||
assert.ok(process.env.GIT_IO_TRACE || samples.some(({ filterMs, loopGapMs }) => filterMs < 20 && loopGapMs < 20), 'no responsive filter sample: ' + JSON.stringify(samples));
|
||||
// Alias identity remains useful after deletion: no per-event realpath can recover this file.
|
||||
callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]);
|
||||
assert.equal(notifications, 1);
|
||||
await marker('REFRESH_START');
|
||||
const snapshot = await service.getSnapshot(worktree);
|
||||
assert.equal(snapshot.git.isGit, true);
|
||||
assert.equal(snapshot.git.isPaseoOwnedWorktree, true);
|
||||
await runGitCommand(['status', '--porcelain'], { cwd: alias, logger });
|
||||
await marker('REFRESH_END');
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
await service.dispose();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
`;
|
||||
for (const tracing of [false, true]) {
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
...(tracing ? ["--trace-sync-io"] : []),
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
source,
|
||||
],
|
||||
{
|
||||
cwd: repositoryRoot,
|
||||
env: { ...process.env, GIT_IO_TRACE: tracing ? "1" : "" },
|
||||
timeout: 30_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
},
|
||||
).catch((error) => {
|
||||
throw new Error(String(error.stderr).replaceAll(/at .*/g, "[child stack omitted]"));
|
||||
});
|
||||
for (const phase of ["FILTER", "REFRESH"]) {
|
||||
const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0];
|
||||
expect(output, `${phase}: synchronous filesystem IO`).toBe("");
|
||||
}
|
||||
expect(JSON.parse(stdout.trim().split("\n")[0])).toMatchObject({ fsCalls: 0 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Git event identity preserves Windows namespace, case and subtree boundaries", async () => {
|
||||
const { GitObservationPaths } = await import("./git-observation-paths.js");
|
||||
const paths = await GitObservationPaths.create("C:\\paseo-observation-contract");
|
||||
const accept = paths.createEventFilter(["C:\\paseo-observation-contract\\Build"]);
|
||||
expect(paths.relative("\\\\?\\C:\\PASEO-OBSERVATION-CONTRACT\\refs\\heads\\MixedCase")).toBe(
|
||||
"refs/heads/MixedCase",
|
||||
);
|
||||
expect(accept("\\\\?\\C:\\paseo-observation-contract\\build\\noise")).toBe(false);
|
||||
expect(accept("C:\\paseo-observation-contract\\BuildSibling\\file")).toBe(true);
|
||||
});
|
||||
|
||||
test("concurrent alias requests establish one working-tree subscription", async () => {
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-concurrent-")));
|
||||
const repo = path.join(root, "repo");
|
||||
const alias = path.join(root, "alias");
|
||||
execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" });
|
||||
await symlink(repo, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
let subscriptions = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(root, "home"),
|
||||
deps: {
|
||||
subscribe: async () => {
|
||||
subscriptions++;
|
||||
return { updateIgnore: async () => {}, unsubscribe: async () => {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const watches = await Promise.all([
|
||||
service.requestWorkingTreeWatch(repo, () => {}),
|
||||
service.requestWorkingTreeWatch(alias, () => {}),
|
||||
]);
|
||||
expect(subscriptions).toBe(1);
|
||||
for (const watch of watches) watch.unsubscribe();
|
||||
} finally {
|
||||
await service.dispose();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => {
|
||||
const { mkdir, symlink } = await import("node:fs/promises");
|
||||
const { GitObservationPaths } = await import("./git-observation-paths.js");
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-")));
|
||||
const repository = path.join(root, "repository");
|
||||
const parentAlias = path.join(root, "parent-alias");
|
||||
const workspaceAlias = path.join(root, "workspace-alias");
|
||||
const literalTarget = path.join(parentAlias, "repository");
|
||||
const linkType = process.platform === "win32" ? "junction" : "dir";
|
||||
try {
|
||||
await mkdir(repository);
|
||||
await symlink(root, parentAlias, linkType);
|
||||
await symlink(literalTarget, workspaceAlias, linkType);
|
||||
const paths = await GitObservationPaths.create(workspaceAlias);
|
||||
const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]);
|
||||
for (const spelling of [workspaceAlias, literalTarget, repository]) {
|
||||
expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt");
|
||||
expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1189,7 +1189,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort());
|
||||
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]);
|
||||
});
|
||||
|
||||
first.unsubscribe();
|
||||
@@ -1624,12 +1624,6 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher.subscribe).toHaveBeenCalledWith(
|
||||
GIT_DIR,
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
@@ -1648,17 +1642,14 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
[60_000, 6],
|
||||
] as const) {
|
||||
await vi.advanceTimersByTimeAsync(elapsedMs);
|
||||
await vi.waitFor(() => {
|
||||
expect(pollCount()).toBe(expectedPolls);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
expect(pollCount()).toBe(expectedPolls);
|
||||
}
|
||||
|
||||
isDirty = true;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await vi.waitFor(() => expect(pollCount()).toBe(7));
|
||||
expect(pollCount()).toBe(7);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.waitFor(() => expect(pollCount()).toBe(8));
|
||||
expect(pollCount()).toBe(8);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -1869,10 +1860,8 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
|
||||
});
|
||||
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
|
||||
|
||||
openedSubscription.resolve({
|
||||
|
||||
@@ -991,7 +991,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flushPromises();
|
||||
|
||||
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2));
|
||||
expect(subscribe).toHaveBeenCalledTimes(2);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
@@ -2221,7 +2221,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
await service.dispose();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => {
|
||||
|
||||
@@ -42,13 +42,18 @@ import {
|
||||
type ForgeResolver,
|
||||
} from "../services/forge-resolver.js";
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js";
|
||||
import {
|
||||
createRealpathAwarePathMatcher,
|
||||
getRealpathAwareRelativePath,
|
||||
isPathInsideRoot,
|
||||
isRealpathInsideRoot,
|
||||
looksLikeDefiniteWindowsPath,
|
||||
} from "../utils/path.js";
|
||||
import {
|
||||
createRunGitCommand,
|
||||
runGitCommand,
|
||||
type RunGitCommand,
|
||||
} from "../utils/run-git-command.js";
|
||||
import { GitObservationPaths } from "./git-observation-paths.js";
|
||||
import { branchNameFromRef } from "../utils/worktree-metadata.js";
|
||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
@@ -412,7 +417,6 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error {
|
||||
}
|
||||
|
||||
interface WorkspaceGitTarget {
|
||||
gitDirPaths: GitObservationPaths | null;
|
||||
cwd: string;
|
||||
listeners: Set<WorkspaceGitListener>;
|
||||
workingTreeWatchTarget: WorkingTreeWatchTarget | null;
|
||||
@@ -439,7 +443,6 @@ interface WorkspaceGitTarget {
|
||||
}
|
||||
|
||||
interface RepoGitTarget {
|
||||
paths: GitObservationPaths;
|
||||
repoGitRoot: string;
|
||||
cwd: string;
|
||||
workspaceKeys: Set<string>;
|
||||
@@ -467,8 +470,6 @@ interface RepoMetadataWorkspaceRefresh {
|
||||
}
|
||||
|
||||
interface WorkingTreeWatchTarget {
|
||||
paths: GitObservationPaths;
|
||||
acceptsEvent: (candidate: string) => boolean;
|
||||
cwd: string;
|
||||
watchPath: string;
|
||||
repoRoot: string | null;
|
||||
@@ -1164,8 +1165,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private async resolveWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
|
||||
const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null;
|
||||
this.assertNotDisposed();
|
||||
const targetCwd = repoRoot ?? cwd;
|
||||
const existingTarget = this.workingTreeWatchTargets.get(targetCwd);
|
||||
if (existingTarget) {
|
||||
@@ -1180,7 +1179,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return target;
|
||||
}
|
||||
|
||||
const watchPath = repoRoot && cwdPaths?.matches(repoRoot) ? cwd : targetCwd;
|
||||
const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd;
|
||||
const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => {
|
||||
if (this.workingTreeWatchSetups.get(targetCwd) === setup) {
|
||||
this.workingTreeWatchSetups.delete(targetCwd);
|
||||
@@ -1199,7 +1198,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private createWorkspaceTarget(cwd: string): WorkspaceGitTarget {
|
||||
const target: WorkspaceGitTarget = {
|
||||
gitDirPaths: null,
|
||||
cwd,
|
||||
listeners: new Set(),
|
||||
workingTreeWatchTarget: null,
|
||||
@@ -1301,11 +1299,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot);
|
||||
const gitDir = facts.absoluteGitDir;
|
||||
const gitDirPaths = target.gitDirPaths?.matches(gitDir)
|
||||
? target.gitDirPaths
|
||||
: await GitObservationPaths.create(gitDir);
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) return;
|
||||
target.gitDirPaths = gitDirPaths;
|
||||
const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir));
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||
return;
|
||||
@@ -1338,7 +1331,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const promise = this.deps
|
||||
.getCheckoutSnapshotFacts(target.cwd, context)
|
||||
.then((facts) => {
|
||||
if (!target.closed) target.latestFacts = facts;
|
||||
target.latestFacts = facts;
|
||||
return facts;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -1366,11 +1359,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const ignoredDirectories = repoRoot
|
||||
? (await this.loadIgnoredDirs(watchPath)).ignored
|
||||
: new Set<string>();
|
||||
const paths = await GitObservationPaths.create(watchPath);
|
||||
this.assertNotDisposed();
|
||||
const target: WorkingTreeWatchTarget = {
|
||||
paths,
|
||||
acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]),
|
||||
cwd,
|
||||
watchPath,
|
||||
repoRoot,
|
||||
@@ -1534,14 +1523,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (watcherErrored) {
|
||||
return;
|
||||
}
|
||||
events = this.relevantWorkingTreeEvents(target, events);
|
||||
if (events.length === 0) return;
|
||||
const discovered = this.noteWorkingTreeDirectories(target, events);
|
||||
if (events.some((event) => basename(event.path) === ".gitignore")) {
|
||||
void this.refreshWorkingTreeIgnoredDirectories(target);
|
||||
} else if (discovered) {
|
||||
this.scheduleWorkingTreeIgnoreRefresh(target);
|
||||
}
|
||||
if (!this.hasRelevantWorkingTreeEvent(target, events)) {
|
||||
return;
|
||||
}
|
||||
this.notifyWorkingTreeChanged(target, "working-tree-watch");
|
||||
},
|
||||
{ ignore },
|
||||
@@ -1773,15 +1763,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
this.removeDeletedKnownDirectories(target, events);
|
||||
const gitDir = join(target.watchPath, ".git");
|
||||
// Event paths have already been mapped into this subscription's established namespace.
|
||||
const matchesWatchPath = target.paths.matches;
|
||||
// `matchesWatchPath` is realpath-aware because it compares an event-reported
|
||||
// directory against `target.watchPath` itself — a config-time root that can
|
||||
// legitimately differ from the watcher's reported form (e.g. a symlinked
|
||||
// temp dir). It is now gated behind the plain Set lookup below, so it only
|
||||
// runs for a directory this target has never seen before, not on every
|
||||
// event.
|
||||
const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath);
|
||||
// One new directory is enough: the scheduled Git query inventories every
|
||||
// ignored directory that exists by then. Stop before a large event batch
|
||||
// multiplies containment checks across all its new directories.
|
||||
for (const event of events) {
|
||||
const directory = dirname(event.path);
|
||||
// Cheap Set lookup first — the steady-state case where the directory is
|
||||
// already known do not need another root comparison.
|
||||
// already known must never reach the realpath-aware matcher below.
|
||||
if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) {
|
||||
continue;
|
||||
}
|
||||
@@ -1960,22 +1955,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target.pendingKnownDirectories.clear();
|
||||
}
|
||||
|
||||
private relevantWorkingTreeEvents(
|
||||
private hasRelevantWorkingTreeEvent(
|
||||
target: WorkingTreeWatchTarget,
|
||||
events: FileChange[],
|
||||
): FileChange[] {
|
||||
const relevant: FileChange[] = [];
|
||||
// Watch backends may report the same path more than once in a batch.
|
||||
const acceptedPaths = new Map<string, boolean>();
|
||||
for (const event of events) {
|
||||
let accepted = acceptedPaths.get(event.path);
|
||||
if (accepted === undefined) {
|
||||
accepted = target.acceptsEvent(event.path);
|
||||
acceptedPaths.set(event.path, accepted);
|
||||
): boolean {
|
||||
const gitDir = join(target.watchPath, ".git");
|
||||
const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath);
|
||||
return events.some((event) => {
|
||||
// Directory metadata changes at the watch root do not change Git state.
|
||||
// FSEvents may emit these when every changed descendant was ignored.
|
||||
if (matchesWatchPath(event.path)) {
|
||||
return false;
|
||||
}
|
||||
if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) });
|
||||
}
|
||||
return relevant;
|
||||
if (isRealpathInsideRoot(gitDir, event.path)) {
|
||||
return false;
|
||||
}
|
||||
for (const ignoredDirectory of target.ignoredDirectories) {
|
||||
if (isRealpathInsideRoot(ignoredDirectory, event.path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise<void> {
|
||||
@@ -2030,7 +2031,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) {
|
||||
target.ignoredDirectories = ignoredDirectories;
|
||||
target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target));
|
||||
this.pruneKnownDirectories(target);
|
||||
}
|
||||
if (target.fallbackPolling) {
|
||||
@@ -2152,15 +2152,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = await GitObservationPaths.create(repoGitRoot);
|
||||
if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return;
|
||||
const establishedTarget = this.repoTargets.get(repoGitRoot);
|
||||
if (establishedTarget) {
|
||||
establishedTarget.workspaceKeys.add(workspaceTarget.cwd);
|
||||
return;
|
||||
}
|
||||
const repoTarget: RepoGitTarget = {
|
||||
paths,
|
||||
repoGitRoot,
|
||||
cwd: workspaceTarget.cwd,
|
||||
workspaceKeys: new Set([workspaceTarget.cwd]),
|
||||
@@ -2213,11 +2205,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target: RepoGitTarget,
|
||||
options?: { replaceFallback?: boolean },
|
||||
): Promise<boolean> {
|
||||
const paths = target.paths;
|
||||
const ignore = getPrunedGitMetadataPaths("common").map((path) =>
|
||||
join(target.repoGitRoot, path),
|
||||
);
|
||||
const matchesRepoGitRoot = paths.matches;
|
||||
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
|
||||
const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot);
|
||||
let openedSubscription: FileObserverSubscription | null = null;
|
||||
let watcherErrored = false;
|
||||
@@ -2232,9 +2223,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const subscription = await this.subscribeWithDeadline(
|
||||
target.repoGitRoot,
|
||||
(error, events) => {
|
||||
const liveEvents = canary.filterEvents(
|
||||
events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })),
|
||||
);
|
||||
const liveEvents = canary.filterEvents(events);
|
||||
if (error) {
|
||||
if (watcherErrored) {
|
||||
return;
|
||||
@@ -2260,7 +2249,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const relevantEvents = liveEvents.filter(
|
||||
(event) =>
|
||||
!matchesRepoGitRoot(event.path) &&
|
||||
ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)),
|
||||
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
|
||||
);
|
||||
if (relevantEvents.length > 0) {
|
||||
const immediateEvents = target.fetchInFlight
|
||||
@@ -2373,7 +2362,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
events: FileChange[],
|
||||
): Map<string, RepoMetadataWorkspaceRefresh> | null {
|
||||
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
|
||||
const matchesRepoGitRoot = target.paths.matches;
|
||||
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
|
||||
|
||||
for (const event of events) {
|
||||
if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null;
|
||||
@@ -2388,7 +2377,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
): void {
|
||||
const workspaceKeys = new Set<string>();
|
||||
for (const event of events) {
|
||||
const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/");
|
||||
const commonRelativePath = getRealpathAwareRelativePath(
|
||||
target.repoGitRoot,
|
||||
event.path,
|
||||
)?.replaceAll("\\", "/");
|
||||
if (commonRelativePath === "config" || commonRelativePath === "info/exclude") {
|
||||
for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey);
|
||||
continue;
|
||||
@@ -2398,10 +2390,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (
|
||||
facts?.isGit &&
|
||||
facts.absoluteGitDir &&
|
||||
this.workspaceTargets
|
||||
.get(workspaceKey)
|
||||
?.gitDirPaths?.relative(event.path)
|
||||
?.replaceAll("\\", "/") === "config.worktree"
|
||||
getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") ===
|
||||
"config.worktree"
|
||||
) {
|
||||
workspaceKeys.add(workspaceKey);
|
||||
}
|
||||
@@ -2417,7 +2407,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean {
|
||||
const relativePath = target.paths.relative(event.path);
|
||||
const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
|
||||
const effect = classifyGitMetadataPath("common", relativePath ?? "");
|
||||
return (
|
||||
(effect.kind === "ref" && effect.namespace === "remote") ||
|
||||
@@ -2429,12 +2419,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private routeRepoMetadataEvent(
|
||||
target: RepoGitTarget,
|
||||
event: FileChange,
|
||||
matchesRepoGitRoot: (candidate: string) => boolean,
|
||||
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): boolean {
|
||||
if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true;
|
||||
|
||||
const commonRelativePath = target.paths.relative(event.path);
|
||||
const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
|
||||
const effect = classifyGitMetadataPath("common", commonRelativePath ?? "");
|
||||
switch (effect.kind) {
|
||||
case "ignore":
|
||||
@@ -2486,7 +2476,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private routePrivateGitDirEvent(
|
||||
target: RepoGitTarget,
|
||||
event: FileChange,
|
||||
matchesRepoGitRoot: (candidate: string) => boolean,
|
||||
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): boolean {
|
||||
let matched = false;
|
||||
@@ -2495,10 +2485,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = this.workspaceTargets
|
||||
.get(workspaceKey)
|
||||
?.gitDirPaths?.relative(event.path);
|
||||
if (relativePath == null) continue;
|
||||
const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path);
|
||||
if (relativePath === null) continue;
|
||||
matched = true;
|
||||
const effect = classifyGitMetadataPath("worktree", relativePath);
|
||||
if (effect.kind === "owner") {
|
||||
@@ -2512,7 +2500,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private routeMainCheckoutMetadata(
|
||||
target: RepoGitTarget,
|
||||
matchesRepoGitRoot: (candidate: string) => boolean,
|
||||
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
|
||||
refreshBase: boolean,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): void {
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch);
|
||||
pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch);
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, workspaceId: workspace.workspaceId },
|
||||
|
||||
@@ -343,8 +343,8 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
const repoRoot = createTempDir("paseo-branch-config-");
|
||||
const worktreeRoot = createTempDir("paseo-branch-worktree-");
|
||||
mkdirSync(path.join(worktreeRoot, ".git"));
|
||||
await writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" });
|
||||
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, {
|
||||
writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" });
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, {
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
});
|
||||
writeConfig(repoRoot, {
|
||||
|
||||
@@ -854,7 +854,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
second.worktree.worktreePath,
|
||||
"remote.paseo-pr-14.url",
|
||||
);
|
||||
const metadata = await readPaseoWorktreeMetadata(second.worktree.worktreePath);
|
||||
const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath);
|
||||
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
|
||||
|
||||
expect(second.worktree.branchName).toBe("feature/gitlab-mr-1");
|
||||
@@ -1113,7 +1113,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
writeFileSync(
|
||||
await getPaseoWorktreeMetadataPath(second.worktree.worktreePath),
|
||||
getPaseoWorktreeMetadataPath(second.worktree.worktreePath),
|
||||
`${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => {
|
||||
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
|
||||
git(["worktree", "add", "-b", "feature", worktreeDir], repoDir);
|
||||
commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work");
|
||||
await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
||||
writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
||||
|
||||
const { baseRef, commits } = await listCheckoutCommits({
|
||||
cwd: worktreeDir,
|
||||
|
||||
@@ -2859,7 +2859,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -2899,7 +2899,7 @@ const x = 1;
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir });
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "placeholder",
|
||||
@@ -2936,7 +2936,7 @@ const x = 1;
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir });
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/pinned",
|
||||
@@ -2975,7 +2975,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -2997,7 +2997,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -3062,7 +3062,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -3114,7 +3114,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/gitlab-mr",
|
||||
@@ -3128,9 +3128,7 @@ const x = 1;
|
||||
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
||||
|
||||
await renameCurrentBranch(workspaceCwd, "feature/renamed");
|
||||
expect(
|
||||
(await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget,
|
||||
).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({
|
||||
localBranchName: "feature/renamed",
|
||||
});
|
||||
const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
@@ -3166,7 +3164,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/placeholder",
|
||||
@@ -3176,7 +3174,7 @@ const x = 1;
|
||||
|
||||
await renameCurrentBranch(workspaceDir, "feature/generated");
|
||||
|
||||
expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({
|
||||
headRef: "feature/generated",
|
||||
localBranchName: "feature/generated",
|
||||
});
|
||||
@@ -3953,7 +3951,7 @@ const x = 1;
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome });
|
||||
@@ -3972,7 +3970,7 @@ const x = 1;
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import {
|
||||
open as openFile,
|
||||
readFile,
|
||||
stat as statFile,
|
||||
mkdtemp,
|
||||
copyFile,
|
||||
realpath,
|
||||
access,
|
||||
rm,
|
||||
} from "fs/promises";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
@@ -1112,7 +1104,7 @@ async function getMainRepoRootFromCommonDir(
|
||||
if (!commonDir) {
|
||||
throw new Error("Not in a git repository");
|
||||
}
|
||||
const normalized = await realpath(commonDir);
|
||||
const normalized = realpathSync(commonDir);
|
||||
|
||||
if (basename(normalized) === ".git") {
|
||||
return dirname(normalized);
|
||||
@@ -1237,7 +1229,7 @@ export async function renameCurrentBranch(
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (currentBranch) {
|
||||
await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||
rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||
}
|
||||
return { previousBranch, currentBranch };
|
||||
}
|
||||
@@ -1282,8 +1274,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri
|
||||
return metadata?.baseRef ?? metadata?.baseRefName ?? null;
|
||||
}
|
||||
|
||||
async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise<string | null> {
|
||||
return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot));
|
||||
function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null {
|
||||
return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot));
|
||||
}
|
||||
|
||||
async function getStoredBaseRefForCwd(
|
||||
@@ -1557,12 +1549,7 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
const rebaseMergePath = resolve(gitDir, "rebase-merge");
|
||||
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
|
||||
|
||||
if (
|
||||
await access(mergeHeadPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
) {
|
||||
if (existsSync(mergeHeadPath)) {
|
||||
try {
|
||||
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
@@ -1570,16 +1557,7 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(await access(rebaseMergePath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)) ||
|
||||
(await access(rebaseApplyPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
))
|
||||
) {
|
||||
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
|
||||
try {
|
||||
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
@@ -2100,7 +2078,7 @@ export async function getCheckoutSnapshotFacts(
|
||||
}
|
||||
|
||||
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
|
||||
? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
: null;
|
||||
const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata);
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context));
|
||||
|
||||
@@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean {
|
||||
return getRealpathAwareRelativePath(root, candidate) !== null;
|
||||
}
|
||||
|
||||
export function getRelativePathInsideRoot(root: string, candidate: string): string | null {
|
||||
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
|
||||
const compareAsWindows = shouldCompareAsWindows(root, candidate);
|
||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
|
||||
@@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s
|
||||
return compareAsWindows ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
|
||||
function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
|
||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value;
|
||||
const platformNormalized = platformPath.normalize(comparableValue);
|
||||
|
||||
@@ -507,6 +507,7 @@ describe("runGitCommand", () => {
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: process.cwd(),
|
||||
cwdExists: true,
|
||||
timeout: 30_000,
|
||||
maxOutputBytes: 20 * 1024 * 1024,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -519,6 +520,7 @@ describe("runGitCommand", () => {
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: process.cwd(),
|
||||
cwdExists: true,
|
||||
durationMs: expect.any(Number),
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
@@ -528,9 +530,6 @@ describe("runGitCommand", () => {
|
||||
}),
|
||||
"Git command closed",
|
||||
);
|
||||
for (const [record] of trace.mock.calls) {
|
||||
expect(record).not.toHaveProperty("cwdExists");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-zero exit codes that are not accepted and frees the slot", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { Logger } from "pino";
|
||||
import type { ProcessEnvRecord } from "../server/paseo-env.js";
|
||||
import {
|
||||
@@ -310,6 +311,7 @@ function executeGitCommand<Output>(
|
||||
command: "git",
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
cwdExists: existsSync(options.cwd),
|
||||
timeout,
|
||||
maxOutputBytes,
|
||||
acceptExitCodes,
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import pLimit from "p-limit";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { isAbsolute, join, resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
const MAX_WORKTREE_METADATA_BYTES = 64 * 1024;
|
||||
|
||||
const ChangeRequestLookupTargetSchema = z.object({
|
||||
headRef: z.string().min(1),
|
||||
headRepositoryOwner: z.string().min(1).optional(),
|
||||
@@ -95,78 +90,75 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null {
|
||||
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
export async function rebindPaseoWorktreeChangeRequestHint(
|
||||
export function rebindPaseoWorktreeChangeRequestHint(
|
||||
worktreeRoot: string,
|
||||
previousBranch: string,
|
||||
currentBranch: string,
|
||||
): Promise<boolean> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const metadata = await readMetadataFile(root);
|
||||
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
|
||||
if (!metadata || !target) {
|
||||
return false;
|
||||
}
|
||||
): boolean {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
|
||||
if (!metadata || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: {
|
||||
...target,
|
||||
...(target.headRef === previousBranch &&
|
||||
!target.headRepositoryOwner &&
|
||||
target.changeRequestNumber === undefined
|
||||
? { headRef: currentBranch }
|
||||
: {}),
|
||||
localBranchName: currentBranch,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: {
|
||||
...target,
|
||||
...(target.headRef === previousBranch &&
|
||||
!target.headRepositoryOwner &&
|
||||
target.changeRequestNumber === undefined
|
||||
? { headRef: currentBranch }
|
||||
: {}),
|
||||
localBranchName: currentBranch,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||
export function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||
worktreeRoot: string,
|
||||
branch: string,
|
||||
): Promise<boolean> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const metadata = await readMetadataFile(root);
|
||||
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||
return false;
|
||||
}
|
||||
const target = createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branch,
|
||||
localBranchName: branch,
|
||||
});
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: target,
|
||||
});
|
||||
return true;
|
||||
): boolean {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||
return false;
|
||||
}
|
||||
const target = createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branch,
|
||||
localBranchName: branch,
|
||||
});
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: target,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise<string> {
|
||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||
const gitPath = join(worktreeRoot, ".git");
|
||||
let info;
|
||||
try {
|
||||
info = await stat(gitPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error });
|
||||
throw error;
|
||||
if (!existsSync(gitPath)) {
|
||||
throw new Error(`Not a git repository: ${worktreeRoot}`);
|
||||
}
|
||||
if (info.isDirectory()) return gitPath;
|
||||
const gitFileContent = await readBoundedFile(gitPath);
|
||||
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1].trim();
|
||||
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||
|
||||
// In a worktree checkout, `.git` is a file containing `gitdir: <path>`.
|
||||
// In a normal checkout, `.git` is a directory.
|
||||
try {
|
||||
const gitFileContent = readFileSync(gitPath, "utf8");
|
||||
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1].trim();
|
||||
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||
}
|
||||
} catch {
|
||||
// If `.git` is a directory, readFileSync will throw; fall through.
|
||||
}
|
||||
|
||||
return gitPath;
|
||||
}
|
||||
|
||||
export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise<string> {
|
||||
const gitDir = await getGitDirForWorktreeRoot(worktreeRoot);
|
||||
export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
|
||||
const gitDir = getGitDirForWorktreeRoot(worktreeRoot);
|
||||
return join(gitDir, "paseo", "worktree.json");
|
||||
}
|
||||
|
||||
@@ -221,166 +213,120 @@ function assertValidBaseRef(value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePaseoWorktreeMetadata(
|
||||
export function writePaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: {
|
||||
baseRefName: string;
|
||||
baseRef?: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
||||
},
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
assertValidBaseRef(baseRefName);
|
||||
const baseRef = options.baseRef?.trim();
|
||||
if (baseRef) {
|
||||
assertValidBaseRef(baseRef);
|
||||
}
|
||||
): void {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
assertValidBaseRef(baseRefName);
|
||||
const baseRef = options.baseRef?.trim();
|
||||
if (baseRef) {
|
||||
assertValidBaseRef(baseRef);
|
||||
}
|
||||
|
||||
const metadata: PaseoWorktreeMetadata = {
|
||||
version: 1,
|
||||
baseRefName,
|
||||
...(baseRef ? { baseRef } : {}),
|
||||
...(options.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||
: {}),
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, metadata);
|
||||
});
|
||||
const metadata: PaseoWorktreeMetadata = {
|
||||
version: 1,
|
||||
baseRefName,
|
||||
...(baseRef ? { baseRef } : {}),
|
||||
...(options.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||
: {}),
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, metadata);
|
||||
}
|
||||
|
||||
export async function writePaseoWorktreeRuntimeMetadata(
|
||||
export function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { worktreePort: number },
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
|
||||
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
|
||||
}
|
||||
): void {
|
||||
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
|
||||
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
|
||||
}
|
||||
|
||||
const current = await readMetadataFile(root);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
version: 2,
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, next);
|
||||
});
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
version: 2,
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
}
|
||||
|
||||
export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { placeholderBranchName: string },
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const placeholderBranchName = options.placeholderBranchName.trim();
|
||||
if (!placeholderBranchName) {
|
||||
throw new Error("Placeholder branch name is required");
|
||||
}
|
||||
): void {
|
||||
const placeholderBranchName = options.placeholderBranchName.trim();
|
||||
if (!placeholderBranchName) {
|
||||
throw new Error("Placeholder branch name is required");
|
||||
}
|
||||
|
||||
const current = await readMetadataFile(root);
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
"Cannot persist first-agent branch auto-name metadata: missing base metadata",
|
||||
);
|
||||
}
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata");
|
||||
}
|
||||
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...current,
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
},
|
||||
});
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...current,
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
worktreeRoot: string,
|
||||
options: { attemptedAt?: string } = {},
|
||||
): Promise<PaseoWorktreeMetadata | null> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const current = await readMetadataFile(root);
|
||||
if (
|
||||
!current ||
|
||||
current.version !== 2 ||
|
||||
current.firstAgentBranchAutoName?.status !== "pending"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export async function readPaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
): Promise<PaseoWorktreeMetadata | null> {
|
||||
const key = await resolveMetadataIdentity(worktreeRoot);
|
||||
await metadataMutations.get(key)?.catch(() => {});
|
||||
return metadataIo(() => readMetadataFile(key));
|
||||
}
|
||||
|
||||
async function readMetadataFile(worktreeRoot: string): Promise<PaseoWorktreeMetadata | null> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
try {
|
||||
return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath)));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
): PaseoWorktreeMetadata | null {
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also
|
||||
// caps a regular file that grows between stat and read; readFile's whole-file loop does not.
|
||||
async function readBoundedFile(path: string): Promise<string> {
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
const limit = MAX_WORKTREE_METADATA_BYTES;
|
||||
if (!info.isFile() || info.size > limit)
|
||||
throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
|
||||
const buffer = Buffer.alloc(limit + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const { bytesRead } = await file.read(buffer, length, buffer.length - length);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
|
||||
return buffer.toString("utf8", 0, length);
|
||||
} finally {
|
||||
await file.close();
|
||||
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
if (!existsSync(metadataPath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
return PaseoWorktreeMetadataSchema.parse(parsed);
|
||||
}
|
||||
|
||||
export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise<string> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
|
||||
export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`);
|
||||
}
|
||||
return metadata.baseRefName;
|
||||
}
|
||||
|
||||
export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise<number | null> {
|
||||
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
|
||||
export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
@@ -390,61 +336,13 @@ export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promis
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writePaseoWorktreeMetadataFile(
|
||||
function writePaseoWorktreeMetadataFile(
|
||||
worktreeRoot: string,
|
||||
metadata: PaseoWorktreeMetadata,
|
||||
): Promise<void> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const contents = `${JSON.stringify(metadata, null, 2)}\n`;
|
||||
if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) {
|
||||
throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`);
|
||||
}
|
||||
// Never recreate an archived Git directory while a queued mutation is pending.
|
||||
try {
|
||||
await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
const tempPath = `${metadataPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, contents, "utf8");
|
||||
await rename(tempPath, metadataPath);
|
||||
}
|
||||
|
||||
const metadataIo = pLimit(2);
|
||||
const metadataMutations = new Map<string, Promise<unknown>>();
|
||||
|
||||
// Only share in-flight identity resolution: a replaced symlink must not retain an old owner.
|
||||
const metadataIdentities = new Map<string, Promise<string>>();
|
||||
|
||||
function resolveMetadataIdentity(worktreeRoot: string): Promise<string> {
|
||||
const path = resolve(worktreeRoot);
|
||||
const existing = metadataIdentities.get(path);
|
||||
if (existing) return existing;
|
||||
const identity = metadataIo(async () => {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}).finally(() => {
|
||||
if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path);
|
||||
});
|
||||
metadataIdentities.set(path, identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function mutateMetadata<T>(
|
||||
worktreeRoot: string,
|
||||
mutation: (root: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = await resolveMetadataIdentity(worktreeRoot);
|
||||
const previous = metadataMutations.get(key) ?? Promise.resolve();
|
||||
const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key)));
|
||||
metadataMutations.set(key, pending);
|
||||
void pending
|
||||
.finally(() => {
|
||||
if (metadataMutations.get(key) === pending) metadataMutations.delete(key);
|
||||
})
|
||||
.catch(() => {});
|
||||
return pending;
|
||||
): void {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, metadataPath);
|
||||
}
|
||||
|
||||
@@ -21,13 +21,7 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
@@ -131,7 +125,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world"));
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
expect(existsSync(metadataPath)).toBe(true);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
@@ -262,7 +256,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: result.worktreePath,
|
||||
});
|
||||
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
@@ -325,7 +319,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
// baseRefName must be the repo's default branch, not the checked-out branch
|
||||
// itself — otherwise the diff panel compares the branch against itself and
|
||||
// always shows no changes.
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
@@ -376,7 +370,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" });
|
||||
});
|
||||
@@ -412,9 +406,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
expect(result.branchName).toBe("main-1");
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(
|
||||
(await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget,
|
||||
).toEqual({
|
||||
expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({
|
||||
headRef: "main-1",
|
||||
localBranchName: "main-1",
|
||||
});
|
||||
@@ -467,7 +459,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
.trim();
|
||||
expect(currentBranch).toBe("user/feature");
|
||||
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
@@ -574,9 +566,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
"from-origin\n",
|
||||
);
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"),
|
||||
),
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")),
|
||||
).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
@@ -617,7 +607,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
// to the commit the worktree was cut from: "main" resolves local-first, which here is
|
||||
// a different commit than the fork's upstream.
|
||||
expect(
|
||||
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" });
|
||||
|
||||
// An untouched child must report no work of its own. Comparing against the wrong base
|
||||
@@ -704,7 +694,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
).toMatchObject({
|
||||
baseRefName: "release+hotfix",
|
||||
baseRef: "refs/remotes/upstream/release+hotfix",
|
||||
@@ -1627,104 +1617,3 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("async worktree metadata", () => {
|
||||
it("serializes metadata mutations per worktree while another worktree progresses", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-"));
|
||||
const first = join(root, "first");
|
||||
const second = join(root, "second");
|
||||
mkdirSync(join(first, ".git"), { recursive: true });
|
||||
mkdirSync(join(second, ".git"), { recursive: true });
|
||||
try {
|
||||
await writePaseoWorktreeMetadata(first, { baseRefName: "main" });
|
||||
await writePaseoWorktreeMetadata(second, { baseRefName: "release" });
|
||||
const order: string[] = [];
|
||||
const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() =>
|
||||
order.push("first-runtime"),
|
||||
);
|
||||
const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() =>
|
||||
order.push("second-runtime"),
|
||||
);
|
||||
const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, {
|
||||
placeholderBranchName: "topic",
|
||||
}).then(() => order.push("first-naming"));
|
||||
// A read issued while writes are pending observes their completed snapshot.
|
||||
const snapshot = readPaseoWorktreeMetadata(first);
|
||||
await Promise.all([runtime, unrelated, naming]);
|
||||
expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming"));
|
||||
expect(await snapshot).toMatchObject({
|
||||
baseRefName: "main",
|
||||
runtime: { worktreePort: 4101 },
|
||||
firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" },
|
||||
});
|
||||
expect(await readPaseoWorktreeMetadata(second)).toMatchObject({
|
||||
baseRefName: "release",
|
||||
runtime: { worktreePort: 4102 },
|
||||
});
|
||||
const alias = join(root, "first-alias");
|
||||
symlinkSync(first, alias, "dir");
|
||||
await Promise.all([
|
||||
writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }),
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, {
|
||||
placeholderBranchName: "renamed",
|
||||
}),
|
||||
]);
|
||||
expect(await readPaseoWorktreeMetadata(first)).toMatchObject({
|
||||
runtime: { worktreePort: 4103 },
|
||||
firstAgentBranchAutoName: { placeholderBranchName: "renamed" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a queued metadata mutation cannot recreate an archived Git directory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-"));
|
||||
mkdirSync(join(root, ".git"));
|
||||
try {
|
||||
await writePaseoWorktreeMetadata(root, { baseRefName: "main" });
|
||||
const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 });
|
||||
// The mutation is queued in a microtask, so archive wins this ordering.
|
||||
rmSync(join(root, ".git"), { recursive: true });
|
||||
await expect(pending).rejects.toThrow("Not a git repository");
|
||||
expect(existsSync(join(root, ".git"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes a removed worktree under a symlinked ownership root", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-"));
|
||||
const actual = join(root, "actual");
|
||||
const alias = join(root, "worktrees");
|
||||
mkdirSync(actual);
|
||||
symlinkSync(actual, alias, "dir");
|
||||
try {
|
||||
const cwd = join(alias, "project", "removed");
|
||||
expect(
|
||||
await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }),
|
||||
).toMatchObject({ allowed: true, worktreePath: cwd });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized metadata and named pipes without reading them whole", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-"));
|
||||
mkdirSync(join(root, ".git", "paseo"), { recursive: true });
|
||||
try {
|
||||
const path = await getPaseoWorktreeMetadataPath(root);
|
||||
writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) }));
|
||||
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
|
||||
"Invalid Paseo worktree metadata file",
|
||||
);
|
||||
rmSync(path);
|
||||
execFileSync("mkfifo", [path]);
|
||||
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
|
||||
"Invalid Paseo worktree metadata file",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
runWorktreeTeardownCommands,
|
||||
slugify,
|
||||
type CreateWorktreeOptions,
|
||||
type WorktreeConfig,
|
||||
@@ -172,15 +171,7 @@ describe("paseo worktree manager", () => {
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
||||
expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath });
|
||||
// Windows may report the same root through native 8.3 and JS long spellings.
|
||||
const nativeWorktreePath = realpathSync.native(created.worktreePath);
|
||||
await expect(
|
||||
runWorktreeTeardownCommands({
|
||||
worktreePath: created.worktreePath,
|
||||
teardownCwd: nativeWorktreePath,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(ownership.allowed).toBe(true);
|
||||
await expect(
|
||||
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { constants as fsConstants, realpath } from "fs";
|
||||
import { copyFile, mkdir, rm, stat } from "fs/promises";
|
||||
import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs";
|
||||
import { copyFile, rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import pLimit from "p-limit";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
@@ -38,12 +37,7 @@ import { spawnProcess } from "./spawn.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import {
|
||||
expandTilde,
|
||||
getRealpathAwareRelativePath,
|
||||
getRelativePathInsideRoot,
|
||||
isPathInsideRoot,
|
||||
} from "./path.js";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import { terminateWithTreeKill } from "./tree-kill.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
@@ -613,7 +607,7 @@ async function assertPortAvailable(port: number): Promise<void> {
|
||||
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = await getGitCommonDir(worktreePath);
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
// Normal repo/worktree: common dir is <repoRoot>/.git
|
||||
if (basename(normalizedCommonDir) === ".git") {
|
||||
return dirname(normalizedCommonDir);
|
||||
@@ -629,12 +623,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
|
||||
});
|
||||
const topLevel = parseGitRevParsePath(stdout);
|
||||
if (topLevel) {
|
||||
return await normalizePathForOwnership(topLevel);
|
||||
return normalizePathForOwnership(topLevel);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return await normalizePathForOwnership(worktreePath);
|
||||
return normalizePathForOwnership(worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,12 +711,12 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
|
||||
let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
if (worktreePort === null) {
|
||||
worktreePort = await getAvailablePort();
|
||||
const metadata = await readPaseoWorktreeMetadata(options.worktreePath);
|
||||
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
|
||||
if (metadata) {
|
||||
await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
|
||||
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
|
||||
}
|
||||
} else {
|
||||
await assertPortAvailable(worktreePort);
|
||||
@@ -748,7 +742,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||
const teardownCwd = options.teardownCwd ?? options.worktreePath;
|
||||
if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) {
|
||||
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
|
||||
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
|
||||
}
|
||||
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
|
||||
@@ -760,7 +754,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
|
||||
createExternalProcessEnv(process.env, {
|
||||
@@ -837,12 +831,12 @@ function deriveShortAlphanumericHash(value: string): string {
|
||||
export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = await getGitCommonDir(cwd);
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
const repoRoot =
|
||||
basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir;
|
||||
return deriveShortAlphanumericHash(repoRoot);
|
||||
} catch {
|
||||
return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd));
|
||||
return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,46 +904,16 @@ export function mapWorkspaceRelativeCwdToWorktree(input: {
|
||||
return mappedCwd;
|
||||
}
|
||||
|
||||
// Keep the former realpathSync spelling on Windows: promises.realpath uses the
|
||||
// native resolver and can return an 8.3 alias instead of the long path.
|
||||
const resolveOwnershipPath = promisify(realpath);
|
||||
const resolveNativeOwnershipPath = promisify(realpath.native);
|
||||
const ownershipIdentityIo = pLimit(2);
|
||||
|
||||
async function normalizePathForOwnership(input: string): Promise<string> {
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
try {
|
||||
return await ownershipIdentityIo(() => resolveOwnershipPath(input));
|
||||
return realpathSync(input);
|
||||
} catch {
|
||||
return resolve(input);
|
||||
}
|
||||
}
|
||||
|
||||
async function ownershipPathVariants(input: string): Promise<string[]> {
|
||||
const variants = new Set([input, await normalizePathForOwnership(input)]);
|
||||
try {
|
||||
variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input)));
|
||||
} catch {
|
||||
// Missing descendants still match a lexical or established root spelling.
|
||||
}
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
// Keep lexical variants for missing paths: archive must still recognize a removed
|
||||
// worktree below a symlinked worktrees root, whose full realpath no longer exists.
|
||||
async function getOwnershipRelativePath(root: string, candidate: string): Promise<string | null> {
|
||||
const roots = await ownershipPathVariants(root);
|
||||
const candidates = await ownershipPathVariants(candidate);
|
||||
for (const rootPath of roots) {
|
||||
for (const candidatePath of candidates) {
|
||||
const relative = getRelativePathInsideRoot(rootPath, candidatePath);
|
||||
if (relative !== null) return relative;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise<string> {
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
function resolveRepoRootFromGitCommonDir(commonDir: string): string {
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
return basename(normalizedCommonDir) === ".git"
|
||||
? dirname(normalizedCommonDir)
|
||||
: normalizedCommonDir;
|
||||
@@ -959,25 +923,25 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
cwd: string,
|
||||
options?: PaseoWorktreeOwnershipOptions,
|
||||
): Promise<PaseoWorktreeOwnership> {
|
||||
const resolvedCwd = await normalizePathForOwnership(cwd);
|
||||
const resolvedCwd = normalizePathForOwnership(cwd);
|
||||
|
||||
// repoRoot is best-effort: git may be unreachable from the worktree (e.g. a
|
||||
// previous archive attempt removed the admin dir before the working tree
|
||||
// could be fully cleaned up). We still want to allow archiving in that case.
|
||||
let repoRoot: string | undefined;
|
||||
if (options?.knownGitCommonDir) {
|
||||
repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
|
||||
} else if (options?.knownGitCommonDir === undefined) {
|
||||
try {
|
||||
const gitCommonDir = await getGitCommonDir(cwd);
|
||||
repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
|
||||
const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd);
|
||||
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
|
||||
|
||||
// Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
|
||||
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
|
||||
@@ -1048,9 +1012,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise<string> {
|
||||
function resolveWorktreeCreatedAtIso(worktreePath: string): string {
|
||||
try {
|
||||
const stats = await stat(worktreePath);
|
||||
const stats = statSync(worktreePath);
|
||||
const birthtimeMs = stats.birthtimeMs;
|
||||
const createdAtMs =
|
||||
Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs;
|
||||
@@ -1075,15 +1039,12 @@ export async function listPaseoWorktrees({
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
const roots = await ownershipPathVariants(projectWorktreesRoot);
|
||||
const worktrees: PaseoWorktreeInfo[] = [];
|
||||
// Inspect foreign paths sequentially; a large worktree list must not consume the FS pool.
|
||||
for (const entry of parseWorktreeList(stdout)) {
|
||||
const path = await normalizePathForOwnership(entry.path);
|
||||
if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue;
|
||||
worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) });
|
||||
}
|
||||
return worktrees;
|
||||
return parseWorktreeList(stdout)
|
||||
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
|
||||
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
|
||||
.map((entry) =>
|
||||
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DeletePaseoWorktreeOptions {
|
||||
@@ -1122,7 +1083,7 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
|
||||
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
|
||||
const resolvedRequested = await normalizePathForOwnership(requestedPath);
|
||||
const resolvedRequested = normalizePathForOwnership(requestedPath);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
|
||||
paseoHome,
|
||||
worktreesRoot: worktreesBaseRoot,
|
||||
@@ -1130,7 +1091,7 @@ export async function deletePaseoWorktree({
|
||||
const resolvedWorktree =
|
||||
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
|
||||
|
||||
const relativeWorktreePath = await getOwnershipRelativePath(
|
||||
const relativeWorktreePath = getRealpathAwareRelativePath(
|
||||
resolvedWorktreesRoot,
|
||||
resolvedWorktree,
|
||||
);
|
||||
@@ -1246,12 +1207,12 @@ export const createWorktree = async ({
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
await mkdir(dirname(worktreePath), { recursive: true });
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (await pathExists(finalWorktreePath)) {
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
@@ -1261,7 +1222,7 @@ export const createWorktree = async ({
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = await normalizePathForOwnership(finalWorktreePath);
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
@@ -1278,7 +1239,7 @@ export const createWorktree = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await writePaseoWorktreeMetadata(worktreePath, {
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
|
||||
Reference in New Issue
Block a user