Compare commits

..
Author SHA1 Message Date
odoo-mvds 65ce4b590a fix(app): remove restored agent tabs from foreign workspaces (#7956)
A persisted explicit pin outlives archive recovery because its agent never joins the unrelated workspace directory. Drop positively foreign pins after hydration and let existing reconciliation remove only the misplaced view. Preserve unknown ownership and legitimate historical opens.

Adapts the repair proposed by kaicot in getpaseo/paseo#6901 to current main, with persisted-store and real-browser regression evidence. Refs getpaseo/paseo#6900.
2026-10-26 17:54:25 +00:00
30 changed files with 590 additions and 908 deletions
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",
@@ -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,
+12 -14
View File
@@ -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 });
+9 -31
View File
@@ -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));
+2 -2
View File
@@ -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,
+136 -238
View File
@@ -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);
}
+10 -121
View File
@@ -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 });
}
});
});
+1 -10
View File
@@ -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({
+35 -74
View File
@@ -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