Make workspace git registration nonblocking (#542)

This commit is contained in:
Mohamed Boudra
2026-04-23 18:21:59 +08:00
committed by GitHub
parent 083cd34db4
commit dfc8223bcc
13 changed files with 557 additions and 318 deletions
@@ -85,6 +85,17 @@ function DisabledRenderCountProbe({
return null; return null;
} }
function HookResultProbe({
onResult,
serverId,
}: {
onResult: (result: ReturnType<typeof useSidebarWorkspacesList>) => void;
serverId: string;
}): null {
onResult(useSidebarWorkspacesList({ serverId }));
return null;
}
describe("applyStoredOrdering", () => { describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => { it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({ const result = applyStoredOrdering({
@@ -206,6 +217,7 @@ describe("useSidebarWorkspacesList", () => {
act(() => { act(() => {
getHostRuntimeStore().syncHosts([]); getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession("srv-disabled"); useSessionStore.getState().clearSession("srv-disabled");
useSessionStore.getState().clearSession("srv-loading");
useSidebarOrderStore.setState({ useSidebarOrderStore.setState({
projectOrderByServerId: {}, projectOrderByServerId: {},
workspaceOrderByServerAndProject: {}, workspaceOrderByServerAndProject: {},
@@ -234,6 +246,30 @@ describe("useSidebarWorkspacesList", () => {
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({}); expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
}); });
it("keeps the sidebar in initial load until workspace hydration succeeds", async () => {
const onResult = vi.fn();
act(() => {
useSessionStore.getState().initializeSession("srv-loading", null as unknown as DaemonClient);
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookResultProbe, { serverId: "srv-loading", onResult }));
});
expect(onResult).toHaveBeenLastCalledWith(
expect.objectContaining({
projects: [],
isLoading: true,
isInitialLoad: true,
}),
);
});
it("does not subscribe to order updates while disabled", async () => { it("does not subscribe to order updates while disabled", async () => {
const onRender = vi.fn(); const onRender = vi.fn();
@@ -303,8 +303,7 @@ export function useSidebarWorkspacesList(options?: {
})(); })();
}, [connectionStatus, isActive, runtime, serverId]); }, [connectionStatus, isActive, runtime, serverId]);
const isLoading = const isLoading = isActive && Boolean(serverId) && !hasHydratedWorkspaces;
isActive && Boolean(serverId) && connectionStatus === "online" && !hasHydratedWorkspaces;
const isInitialLoad = isLoading && projects.length === 0; const isInitialLoad = isLoading && projects.length === 0;
const isRevalidating = false; const isRevalidating = false;
@@ -1274,11 +1274,9 @@ describe("agent snapshot MCP serialization", () => {
const tool = (server as any)._registeredTools["list_agents"]; const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({}); const response = await tool.callback({});
expect(response.structuredContent.agents.map((agent: { id: string }) => agent.id)).toEqual([ const agentIds = response.structuredContent.agents.map((agent: { id: string }) => agent.id);
"in-cwd", expect(agentIds).toHaveLength(3);
"in-child-cwd", expect(new Set(agentIds)).toEqual(new Set(["in-cwd", "in-child-cwd", "stored-in-cwd"]));
"stored-in-cwd",
]);
}); });
it("allows explicit cwd, status, archive, time, and limit filters for list_agents", async () => { it("allows explicit cwd, status, archive, time, and limit filters for list_agents", async () => {
@@ -236,8 +236,7 @@ function createGitHubServiceStub(): GitHubService {
function createWorkspaceGitServiceStub(): WorkspaceGitService { function createWorkspaceGitServiceStub(): WorkspaceGitService {
return { return {
subscribe: async (params) => ({ registerWorkspace: () => ({
initial: createWorkspaceGitSnapshot(params.cwd),
unsubscribe: () => {}, unsubscribe: () => {},
}), }),
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd), peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
+19 -30
View File
@@ -217,7 +217,7 @@ import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "./pase
import { toWorktreeWireError } from "./worktree-errors.js"; import { toWorktreeWireError } from "./worktree-errors.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
type GitMutationRefreshReason = type GitMutationRefreshReason =
| "commit-changes" | "commit-changes"
| "pull" | "pull"
@@ -326,7 +326,7 @@ type WorkspaceGitWatchTarget = {
debounceTimer: ReturnType<typeof setTimeout> | null; debounceTimer: ReturnType<typeof setTimeout> | null;
refreshPromise: Promise<void> | null; refreshPromise: Promise<void> | null;
refreshQueued: boolean; refreshQueued: boolean;
latestFingerprint: string | null; latestDescriptorStateKey: string | null;
lastBranchName: string | null; lastBranchName: string | null;
}; };
@@ -897,11 +897,9 @@ export class Session {
} }
} }
async primeWorkspaceGitWatchFingerprintForWorkspace( async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
workspace: PersistedWorkspaceRecord,
): Promise<void> {
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace); const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
await this.primeWorkspaceGitWatchFingerprints([descriptor]); this.syncWorkspaceGitObservers([descriptor]);
} }
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> { async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
@@ -917,7 +915,7 @@ export class Session {
} }
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> { async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace); await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
} }
@@ -4360,9 +4358,9 @@ export class Session {
this.workspaceGitSubscriptions.delete(normalizedCwd); this.workspaceGitSubscriptions.delete(normalizedCwd);
} }
private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string { private workspaceGitDescriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
if (!workspace) { if (!workspace) {
return WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT; return WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY;
} }
return JSON.stringify([ return JSON.stringify([
workspace.name, workspace.name,
@@ -4378,15 +4376,15 @@ export class Session {
if (!target) { if (!target) {
return false; return false;
} }
const nextFingerprint = this.workspaceGitDescriptorFingerprint(workspace); const nextStateKey = this.workspaceGitDescriptorStateKey(workspace);
if (target.latestFingerprint === nextFingerprint) { if (target.latestDescriptorStateKey === nextStateKey) {
return true; return true;
} }
target.latestFingerprint = nextFingerprint; target.latestDescriptorStateKey = nextStateKey;
return false; return false;
} }
private rememberWorkspaceGitWatchFingerprint( private rememberWorkspaceGitDescriptorState(
workspaceId: string, workspaceId: string,
workspace: WorkspaceDescriptorPayload | null, workspace: WorkspaceDescriptorPayload | null,
): void { ): void {
@@ -4394,29 +4392,20 @@ export class Session {
if (!target) { if (!target) {
return; return;
} }
target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace); target.latestDescriptorStateKey = this.workspaceGitDescriptorStateKey(workspace);
target.lastBranchName = workspace?.name ?? null; target.lastBranchName = workspace?.name ?? null;
} }
private async primeWorkspaceGitWatchFingerprints( private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
workspaces: Iterable<WorkspaceDescriptorPayload>,
): Promise<void> {
for (const workspace of workspaces) { for (const workspace of workspaces) {
const persistedWorkspace = await this.workspaceRegistry.get(workspace.id); this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
if (!persistedWorkspace) {
continue;
}
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.cwd, {
isGit: workspace.projectKind === "git", isGit: workspace.projectKind === "git",
}); });
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.cwd, workspace); this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
} }
} }
private async syncWorkspaceGitWatchTarget( private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void {
cwd: string,
options: { isGit: boolean },
): Promise<void> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd); const normalizedCwd = normalizePersistedWorkspaceId(cwd);
if (!options.isGit) { if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd); this.removeWorkspaceGitSubscription(normalizedCwd);
@@ -4427,7 +4416,7 @@ export class Session {
return; return;
} }
const subscription = await this.workspaceGitService.subscribe( const subscription = this.workspaceGitService.registerWorkspace(
{ cwd: normalizedCwd }, { cwd: normalizedCwd },
(snapshot) => { (snapshot) => {
void this.emitWorkspaceUpdateForCwd(normalizedCwd); void this.emitWorkspaceUpdateForCwd(normalizedCwd);
@@ -6523,7 +6512,7 @@ export class Session {
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName); this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
} }
} }
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace); this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
if (!nextWorkspace) { if (!nextWorkspace) {
subscription.lastEmittedByWorkspaceId.delete(workspaceId); subscription.lastEmittedByWorkspaceId.delete(workspaceId);
@@ -6694,7 +6683,7 @@ export class Session {
} }
const payload = await this.listFetchWorkspacesEntries(request); const payload = await this.listFetchWorkspacesEntries(request);
await this.primeWorkspaceGitWatchFingerprints(payload.entries); this.syncWorkspaceGitObservers(payload.entries);
this.sessionLogger.debug( this.sessionLogger.debug(
{ {
requestId: request.requestId, requestId: request.requestId,
@@ -9,6 +9,7 @@ import {
createPersistedProjectRecord, createPersistedProjectRecord,
createPersistedWorkspaceRecord, createPersistedWorkspaceRecord,
} from "./workspace-registry.js"; } from "./workspace-registry.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
function createWorkspaceRuntimeSnapshot( function createWorkspaceRuntimeSnapshot(
cwd: string, cwd: string,
@@ -68,7 +69,7 @@ function createSessionForWorkspaceGitWatchTests(): {
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>; projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
workspaces: Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>; workspaces: Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>;
workspaceGitService: WorkspaceGitService & { workspaceGitService: WorkspaceGitService & {
subscribe: ReturnType<typeof vi.fn>; registerWorkspace: ReturnType<typeof vi.fn>;
peekSnapshot: ReturnType<typeof vi.fn>; peekSnapshot: ReturnType<typeof vi.fn>;
getSnapshot: ReturnType<typeof vi.fn>; getSnapshot: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>; refresh: ReturnType<typeof vi.fn>;
@@ -99,7 +100,8 @@ function createSessionForWorkspaceGitWatchTests(): {
error: vi.fn(), error: vi.fn(),
}; };
const workspaceGitService = { const workspaceGitService = {
subscribe: vi.fn(async (params: { cwd: string }, listener: WorkspaceGitListener) => { ...createNoopWorkspaceGitService(),
registerWorkspace: vi.fn((params: { cwd: string }, listener: WorkspaceGitListener) => {
const unsubscribe = vi.fn(); const unsubscribe = vi.fn();
subscriptions.push({ subscriptions.push({
params, params,
@@ -107,7 +109,6 @@ function createSessionForWorkspaceGitWatchTests(): {
unsubscribe, unsubscribe,
}); });
return { return {
initial: createWorkspaceRuntimeSnapshot(params.cwd),
unsubscribe, unsubscribe,
}; };
}), }),
@@ -186,7 +187,7 @@ function createSessionForWorkspaceGitWatchTests(): {
}), }),
dispose: () => {}, dispose: () => {},
} as any, } as any,
workspaceGitService: workspaceGitService as any, workspaceGitService,
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -200,7 +201,7 @@ function createSessionForWorkspaceGitWatchTests(): {
emitted, emitted,
projects, projects,
workspaces, workspaces,
workspaceGitService: workspaceGitService as any, workspaceGitService,
subscriptions, subscriptions,
}; };
} }
@@ -275,9 +276,9 @@ describe("workspace git watch targets", () => {
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
expect(workspaceGitService.subscribe).toHaveBeenCalledWith( expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" }, { cwd: "/tmp/repo" },
expect.any(Function), expect.any(Function),
); );
@@ -334,7 +335,7 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(),
}; };
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
emitted.length = 0; emitted.length = 0;
subscriptions[0]?.listener( subscriptions[0]?.listener(
@@ -369,7 +370,7 @@ describe("workspace git watch targets", () => {
error: null, error: null,
requestId: "subscription:/tmp/repo", requestId: "subscription:/tmp/repo",
}); });
expect(workspaceGitService.subscribe).toHaveBeenCalledWith( expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" }, { cwd: "/tmp/repo" },
expect.any(Function), expect.any(Function),
); );
@@ -397,7 +398,7 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(),
}; };
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
emitted.length = 0; emitted.length = 0;
subscriptions[0]?.listener( subscriptions[0]?.listener(
@@ -10,6 +10,7 @@ import type {
SessionOutboundMessage, SessionOutboundMessage,
} from "../shared/messages.js"; } from "../shared/messages.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { import {
createPersistedProjectRecord, createPersistedProjectRecord,
createPersistedWorkspaceRecord, createPersistedWorkspaceRecord,
@@ -112,67 +113,6 @@ function agentIdsFromEntries(entries: Array<{ agent: Pick<AgentSnapshotPayload,
return entries.map((entry) => entry.agent.id); return entries.map((entry) => entry.agent.id);
} }
function createNoopWorkspaceGitService() {
return {
subscribe: async (params: { cwd: string }) => ({
initial: {
cwd: params.cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: (_cwd: string) => null,
getSnapshot: async (cwd: string) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
resolveRepoRemoteUrl: async () => null,
resolveRepoRoot: async (cwd: string) => cwd,
resolveDefaultBranch: async () => "main",
refresh: async () => {},
requestWorkingTreeWatch: async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
function createWorkspaceRuntimeSnapshot( function createWorkspaceRuntimeSnapshot(
cwd: string, cwd: string,
overrides?: { overrides?: {
@@ -301,7 +241,7 @@ function createSessionForWorkspaceTests(
}), }),
dispose: () => {}, dispose: () => {},
} as any, } as any,
workspaceGitService: (options.workspaceGitService ?? createNoopWorkspaceGitService()) as any, workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(),
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -593,7 +533,7 @@ describe("workspace aggregation", () => {
}), }),
dispose: () => {}, dispose: () => {},
} as any, } as any,
workspaceGitService: createNoopWorkspaceGitService() as any, workspaceGitService: createNoopWorkspaceGitService(),
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -745,7 +685,7 @@ describe("workspace aggregation", () => {
}), }),
dispose: () => {}, dispose: () => {},
} as any, } as any,
workspaceGitService: createNoopWorkspaceGitService() as any, workspaceGitService: createNoopWorkspaceGitService(),
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -924,7 +864,7 @@ describe("workspace aggregation", () => {
}), }),
dispose: () => {}, dispose: () => {},
} as any, } as any,
workspaceGitService: createNoopWorkspaceGitService() as any, workspaceGitService: createNoopWorkspaceGitService(),
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -1066,7 +1006,7 @@ describe("workspace aggregation", () => {
}), }),
dispose: () => {}, dispose: () => {},
} as any, } as any,
workspaceGitService: createNoopWorkspaceGitService() as any, workspaceGitService: createNoopWorkspaceGitService(),
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -1683,7 +1623,7 @@ describe("workspace aggregation", () => {
}), }),
dispose: () => {}, dispose: () => {},
} as any, } as any,
workspaceGitService: createNoopWorkspaceGitService() as any, workspaceGitService: createNoopWorkspaceGitService(),
mcpBaseUrl: null, mcpBaseUrl: null,
stt: null, stt: null,
tts: null, tts: null,
@@ -2646,8 +2586,7 @@ describe("workspace aggregation", () => {
}); });
const workspaceGitService = createNoopWorkspaceGitService(); const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.subscribe = vi.fn(async () => ({ workspaceGitService.registerWorkspace = vi.fn(() => ({
initial: runtimeSnapshot,
unsubscribe: () => {}, unsubscribe: () => {},
})); }));
@@ -2728,6 +2667,64 @@ describe("workspace aggregation", () => {
]); ]);
}); });
test("fetch_workspaces_response emits before cold registration-triggered git work starts", async () => {
const events: string[] = [];
const emitted: Array<{ type: string; payload: any }> = [];
const workspaceGitService = createNoopWorkspaceGitService();
const getSnapshot = vi.fn(async (cwd: string) => {
events.push(`git:${cwd}`);
return createWorkspaceRuntimeSnapshot(cwd);
});
workspaceGitService.getSnapshot = getSnapshot;
workspaceGitService.registerWorkspace = vi.fn((params: { cwd: string }) => {
queueMicrotask(() => {
void getSnapshot(params.cwd);
});
return {
unsubscribe: () => {},
};
});
const session = createSessionForWorkspaceTests({
workspaceGitService,
}) as any;
const project = createPersistedProjectRecord({
projectId: "proj-fetch-boundary",
rootPath: "/tmp/repo",
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-fetch-boundary",
projectId: project.projectId,
cwd: "/tmp/repo",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.emit = (message: any) => {
if (message.type === "fetch_workspaces_response") {
events.push("response");
}
emitted.push(message);
};
session.listAgentPayloads = async () => [];
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.list = async () => [workspace];
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-fetch-workspaces-boundary",
subscribe: {},
});
expect(emitted.find((message) => message.type === "fetch_workspaces_response")).toBeDefined();
expect(events[0]).toBe("response");
});
test("workspace_update includes updated runtime fields", async () => { test("workspace_update includes updated runtime fields", async () => {
const emitted: Array<{ type: string; payload: any }> = []; const emitted: Array<{ type: string; payload: any }> = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
@@ -2865,6 +2862,7 @@ describe("workspace aggregation", () => {
projectId: gitProject.projectId, projectId: gitProject.projectId,
projectDisplayName: gitProject.displayName, projectDisplayName: gitProject.displayName,
projectRootPath: gitProject.rootPath, projectRootPath: gitProject.rootPath,
workspaceDirectory: gitWorkspace.cwd,
projectKind: gitProject.kind, projectKind: gitProject.kind,
workspaceKind: gitWorkspace.kind, workspaceKind: gitWorkspace.kind,
name: "main", name: "main",
@@ -2881,6 +2879,7 @@ describe("workspace aggregation", () => {
projectId: directoryProject.projectId, projectId: directoryProject.projectId,
projectDisplayName: directoryProject.displayName, projectDisplayName: directoryProject.displayName,
projectRootPath: directoryProject.rootPath, projectRootPath: directoryProject.rootPath,
workspaceDirectory: directoryWorkspace.cwd,
projectKind: directoryProject.kind, projectKind: directoryProject.kind,
workspaceKind: directoryWorkspace.kind, workspaceKind: directoryWorkspace.kind,
name: "docs", name: "docs",
@@ -0,0 +1,76 @@
import { basename } from "node:path";
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
import {
buildWorkspaceGitMetadataFromSnapshot,
type WorkspaceGitMetadata,
} from "../workspace-git-metadata.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
return {
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
};
}
export function createNoopWorkspaceGitService(
overrides: Partial<WorkspaceGitService> = {},
): WorkspaceGitService {
const service: WorkspaceGitService = {
registerWorkspace: () => ({
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getSnapshot: async (cwd: string) => createNoGitWorkspaceRuntimeSnapshot(cwd),
getCheckoutDiff: async (): Promise<CheckoutDiffResult> => ({ diff: "" }),
validateBranchRef: async () => ({ kind: "not-found" }),
hasLocalBranch: async () => false,
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
return buildWorkspaceGitMetadataFromSnapshot({
cwd,
directoryName: options?.directoryName ?? basename(cwd),
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
});
},
resolveRepoRoot: async (cwd: string) => cwd,
resolveDefaultBranch: async () => "main",
resolveRepoRemoteUrl: async () => null,
refresh: async () => {},
requestWorkingTreeWatch: async () => ({
repoRoot: null,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
...overrides,
};
return service;
}
+58 -51
View File
@@ -1,6 +1,6 @@
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import type { Server as HTTPServer } from "http"; import type { Server as HTTPServer } from "http";
import { join } from "path"; import { basename, join } from "path";
import { hostname as getHostname } from "node:os"; import { hostname as getHostname } from "node:os";
import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js";
@@ -35,7 +35,8 @@ import type {
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { buildProviderRegistry } from "./agent/provider-registry.js"; import { buildProviderRegistry } from "./agent/provider-registry.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js"; import { PushService } from "./push/push-service.js";
import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthState } from "./script-health-monitor.js";
@@ -67,62 +68,68 @@ type WebSocketServerConfig = {
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl { function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
return { return {
subscribe: async ({ cwd }: { cwd: string }) => ({ cwd,
initial: { git: {
cwd, isGit: false,
git: { repoRoot: null,
isGit: false, mainRepoRoot: null,
repoRoot: null, currentBranch: null,
mainRepoRoot: null, remoteUrl: null,
currentBranch: null, isPaseoOwnedWorktree: false,
remoteUrl: null, isDirty: null,
isPaseoOwnedWorktree: false, baseRef: null,
isDirty: null, aheadBehind: null,
aheadBehind: null, aheadOfOrigin: null,
aheadOfOrigin: null, behindOfOrigin: null,
behindOfOrigin: null, hasRemote: false,
diffStat: null, diffStat: null,
}, },
github: { github: {
featuresEnabled: false, featuresEnabled: false,
pullRequest: null, pullRequest: null,
error: null, error: null,
}, },
}, };
}
function createFallbackWorkspaceGitService(): WorkspaceGitService {
return {
registerWorkspace: () => ({
unsubscribe: () => {}, unsubscribe: () => {},
}), }),
peekSnapshot: () => null, peekSnapshot: () => null,
getSnapshot: async (cwd: string) => ({ getSnapshot: async (cwd: string) => createFallbackWorkspaceGitSnapshot(cwd),
cwd, getCheckoutDiff: async () => ({ diff: "" }),
git: { validateBranchRef: async () => ({ kind: "not-found" }),
isGit: false, hasLocalBranch: async () => false,
repoRoot: null, suggestBranchesForCwd: async () => [],
mainRepoRoot: null, listStashes: async () => [],
currentBranch: null, listWorktrees: async () => [],
remoteUrl: null, getWorkspaceGitMetadata: async (cwd: string, options) => {
isPaseoOwnedWorktree: false, const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
isDirty: null, return buildWorkspaceGitMetadataFromSnapshot({
aheadBehind: null, cwd,
aheadOfOrigin: null, directoryName: options?.directoryName ?? basename(cwd),
behindOfOrigin: null, isGit: snapshot.git.isGit,
diffStat: null, repoRoot: snapshot.git.repoRoot,
}, mainRepoRoot: snapshot.git.mainRepoRoot,
github: { currentBranch: snapshot.git.currentBranch,
featuresEnabled: false, remoteUrl: snapshot.git.remoteUrl,
pullRequest: null, });
error: null, },
}, resolveRepoRoot: async (cwd: string) => cwd,
}), resolveDefaultBranch: async () => "main",
resolveRepoRemoteUrl: async () => null,
refresh: async () => {}, refresh: async () => {},
requestWorkingTreeWatch: async (cwd: string) => ({ requestWorkingTreeWatch: async () => ({
repoRoot: cwd, repoRoot: null,
unsubscribe: () => {}, unsubscribe: () => {},
}), }),
scheduleRefreshForCwd: () => {}, scheduleRefreshForCwd: () => {},
dispose: () => {}, dispose: () => {},
} as unknown as WorkspaceGitServiceImpl; };
} }
function createNoopProjectRegistry(): ProjectRegistry { function createNoopProjectRegistry(): ProjectRegistry {
@@ -297,7 +304,7 @@ export class VoiceAssistantWebSocketServer {
private readonly scheduleService: ScheduleService; private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager; private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService; private readonly github: GitHubService;
private readonly workspaceGitService: WorkspaceGitServiceImpl; private readonly workspaceGitService: WorkspaceGitService;
private readonly downloadTokenStore: DownloadTokenStore; private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly daemonConfigStore: DaemonConfigStore; private readonly daemonConfigStore: DaemonConfigStore;
@@ -394,7 +401,7 @@ export class VoiceAssistantWebSocketServer {
getDaemonTcpPort?: () => number | null, getDaemonTcpPort?: () => number | null,
getDaemonTcpHost?: () => string | null, getDaemonTcpHost?: () => string | null,
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null, resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
workspaceGitService?: WorkspaceGitServiceImpl, workspaceGitService?: WorkspaceGitService,
github?: GitHubService, github?: GitHubService,
) { ) {
this.logger = logger.child({ module: "websocket-server" }); this.logger = logger.child({ module: "websocket-server" });
@@ -295,6 +295,33 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose(); service.dispose();
}); });
test("registerWorkspace returns a subscription without waiting for a cold snapshot", async () => {
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
const service = createService({ getCheckoutStatus });
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
expect(getCheckoutStatus).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
subscription.unsubscribe();
service.dispose();
});
test("forced getSnapshot bypasses the internal min-gap and re-shells", async () => { test("forced getSnapshot bypasses the internal min-gap and re-shells", async () => {
let nowMs = 0; let nowMs = 0;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
@@ -315,8 +342,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
test("forced getSnapshot emits even when the fingerprint matches", async () => { test("forced getSnapshot emits even when the fingerprint matches", async () => {
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({ getCheckoutStatus }); const service = createService({ getCheckoutStatus });
await service.getSnapshot("/tmp/repo");
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); await service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
@@ -336,8 +365,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.getSnapshot("/tmp/repo");
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
nowMs = 3_000; nowMs = 3_000;
await service.refresh("/tmp/repo"); await service.refresh("/tmp/repo");
@@ -566,7 +597,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getPullRequestStatus, getPullRequestStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
nowMs = 60_000; nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
@@ -582,6 +614,103 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose(); service.dispose();
}); });
test("self-heal retries workspace observation setup while a listener remains active", async () => {
let nowMs = 0;
const resolveAbsoluteGitDir = vi
.fn<() => Promise<string | null>>()
.mockRejectedValueOnce(new Error("git dir temporarily unavailable"))
.mockResolvedValue("/tmp/repo/.git");
const watch = vi.fn(() => createWatcher() as never);
const service = createService({
resolveAbsoluteGitDir,
watch,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect(watch).not.toHaveBeenCalled();
nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith("/tmp/repo");
subscription.unsubscribe();
service.dispose();
});
test("stale workspace watcher callbacks do not refresh after unsubscribe", async () => {
const watchCallbacks: Array<() => void> = [];
const watch = vi.fn(
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
watchCallbacks.push(callback);
return createWatcher() as never;
},
);
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
getCheckoutStatus,
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
watch,
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
await vi.waitFor(() => {
expect(watchCallbacks.length).toBeGreaterThan(0);
});
const callsBeforeStaleCallback = getCheckoutStatus.mock.calls.length;
subscription.unsubscribe();
watchCallbacks[0]?.();
await vi.advanceTimersByTimeAsync(500);
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
service.dispose();
});
test("stale GitHub poll callbacks do not refresh after unsubscribe", async () => {
let pollStatus: (() => void) | null = null;
const pollUnsubscribe = vi.fn();
const github = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: vi.fn((options: { onStatus: () => void }) => {
pollStatus = options.onStatus;
return { unsubscribe: pollUnsubscribe };
}),
};
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
getCheckoutStatus,
github,
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
await vi.waitFor(() => {
expect(github.retainCurrentPullRequestStatusPoll).toHaveBeenCalledTimes(1);
});
const callsBeforeStaleCallback = getCheckoutStatus.mock.calls.length;
subscription.unsubscribe();
pollStatus?.();
await flushPromises();
expect(pollUnsubscribe).toHaveBeenCalledTimes(1);
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
service.dispose();
});
test("subscription starts GitHub self-heal reads within the fast poll window", async () => { test("subscription starts GitHub self-heal reads within the fast poll window", async () => {
let nowMs = 0; let nowMs = 0;
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = []; const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
@@ -611,7 +740,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github, github,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
nowMs = 20_000; nowMs = 20_000;
await vi.advanceTimersByTimeAsync(20_000); await vi.advanceTimersByTimeAsync(20_000);
@@ -643,7 +773,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
github, github,
}); });
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled(); expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
@@ -658,8 +789,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
const second = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn()); const second = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
await flushPromises();
nowMs = 60_000; nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
@@ -679,14 +811,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
subscription.unsubscribe(); subscription.unsubscribe();
nowMs = 60_000; nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
service.dispose(); service.dispose();
}); });
@@ -698,14 +830,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
service.dispose(); service.dispose();
nowMs = 60_000; nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
}); });
test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => { test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => {
@@ -719,7 +851,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
nowMs = 60_000; nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
@@ -223,14 +223,16 @@ describe("WorkspaceGitServiceImpl", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
test("subscribe returns an initial workspace runtime snapshot", async () => { test("registerWorkspace returns a subscription without an initial snapshot contract", async () => {
const service = createService(); const service = createService();
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo")); expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
expect("initial" in subscription).toBe(false);
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -282,7 +284,8 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
nowMs += 3_000; nowMs += 3_000;
await service.refresh("/tmp/repo"); await service.refresh("/tmp/repo");
@@ -294,7 +297,7 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose(); service.dispose();
}); });
test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => { test("cold getSnapshot calls share one workspace target and cache the snapshot", async () => {
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>(); const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
@@ -313,8 +316,6 @@ describe("WorkspaceGitServiceImpl", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(0); expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
expect((service as any).workspaceTargets.size).toBe(1);
expect((service as any).workspaceTargetSetups.size).toBe(1);
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
@@ -325,8 +326,7 @@ describe("WorkspaceGitServiceImpl", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
expect((service as any).workspaceTargets.size).toBe(1);
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
@@ -347,14 +347,12 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const [first, second] = await Promise.all([ const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
service.subscribe({ cwd: "/tmp/repo" }, vi.fn()), const second = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
service.subscribe({ cwd: "/tmp/repo" }, vi.fn()), await flushPromises();
]);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect((service as any).workspaceTargets.size).toBe(1);
first.unsubscribe(); first.unsubscribe();
second.unsubscribe(); second.unsubscribe();
@@ -372,9 +370,9 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn()); const subscription = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo"));
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
nowMs += 3_000; nowMs += 3_000;
@@ -383,7 +381,6 @@ describe("WorkspaceGitServiceImpl", () => {
expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect((service as any).workspaceTargets.size).toBe(1);
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -392,20 +389,23 @@ describe("WorkspaceGitServiceImpl", () => {
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
const runGitFetch = vi.fn(async () => {}); const runGitFetch = vi.fn(async () => {});
const hasOriginRemote = vi.fn(async () => true); const hasOriginRemote = vi.fn(async () => true);
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
const service = createService({ const service = createService({
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), resolveAbsoluteGitDir,
hasOriginRemote, hasOriginRemote,
runGitFetch, runGitFetch,
}); });
const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
const second = await service.subscribe({ cwd: "/tmp/repo/packages/server" }, vi.fn()); const second = service.registerWorkspace({ cwd: "/tmp/repo/packages/server" }, vi.fn());
await flushPromises(); await vi.waitFor(() => {
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
expect(runGitFetch).toHaveBeenCalledTimes(1);
});
expect(hasOriginRemote).toHaveBeenCalledTimes(1); expect(hasOriginRemote).toHaveBeenCalledTimes(1);
expect(runGitFetch).toHaveBeenCalledTimes(1); expect(runGitFetch).toHaveBeenCalledTimes(1);
expect((service as any).repoTargets.size).toBe(1);
await vi.advanceTimersByTimeAsync(180_000); await vi.advanceTimersByTimeAsync(180_000);
await flushPromises(); await flushPromises();
@@ -452,9 +452,10 @@ describe("WorkspaceGitServiceImpl", () => {
}); });
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); const initialSnapshot = await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh"); expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
await service.refresh("/tmp/repo"); await service.refresh("/tmp/repo");
await flushPromises(); await flushPromises();
@@ -521,9 +522,10 @@ describe("WorkspaceGitServiceImpl", () => {
}); });
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); const initialSnapshot = await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
expect(subscription.initial.git.currentBranch).toBe("main"); expect(initialSnapshot.git.currentBranch).toBe("main");
nowMs += 3_000; nowMs += 3_000;
await service.refresh("/tmp/repo"); await service.refresh("/tmp/repo");
@@ -564,7 +566,8 @@ describe("WorkspaceGitServiceImpl", () => {
}); });
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
await service.getSnapshot("/tmp/repo", { await service.getSnapshot("/tmp/repo", {
force: true, force: true,
@@ -765,10 +768,14 @@ describe("WorkspaceGitServiceImpl", () => {
const service = createService({ getCheckoutShortstat, watch }); const service = createService({ getCheckoutShortstat, watch });
const workspaceListener = vi.fn(); const workspaceListener = vi.fn();
const workspaceSubscription = await service.subscribe({ cwd: "/tmp/repo" }, workspaceListener); const initialSnapshot = await service.getSnapshot("/tmp/repo");
const workspaceSubscription = service.registerWorkspace(
{ cwd: "/tmp/repo" },
workspaceListener,
);
const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn()); const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
expect(workspaceSubscription.initial.git.diffStat).toEqual({ additions: 1, deletions: 0 }); expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 });
const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo"); const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo");
expect(repoRootWatch).toBeDefined(); expect(repoRootWatch).toBeDefined();
@@ -83,13 +83,10 @@ export type WorkspaceGitRuntimeSnapshot = {
}; };
export interface WorkspaceGitService { export interface WorkspaceGitService {
subscribe( registerWorkspace(
params: { cwd: string }, params: { cwd: string },
listener: WorkspaceGitListener, listener: WorkspaceGitListener,
): Promise<{ ): WorkspaceGitSubscription;
initial: WorkspaceGitRuntimeSnapshot;
unsubscribe: () => void;
}>;
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
getSnapshot( getSnapshot(
@@ -139,6 +136,10 @@ export interface WorkspaceGitService {
export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
export type WorkspaceGitSubscription = {
unsubscribe: () => void;
};
export type WorkspaceGitReadOptions = export type WorkspaceGitReadOptions =
| { | {
force?: false; force?: false;
@@ -246,6 +247,9 @@ interface WorkspaceGitTarget {
latestFingerprint: string | null; latestFingerprint: string | null;
lastShellOutAtMs: number | null; lastShellOutAtMs: number | null;
repoGitRoot: string | null; repoGitRoot: string | null;
observationSetupPromise: Promise<void> | null;
observationSetupComplete: boolean;
closed: boolean;
} }
interface RepoGitTarget { interface RepoGitTarget {
@@ -281,7 +285,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly deps: WorkspaceGitServiceDependencies; private readonly deps: WorkspaceGitServiceDependencies;
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>(); private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>(); private readonly repoTargets = new Map<string, RepoGitTarget>();
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>(); private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>(); private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
private readonly branchValidationCache = new Map< private readonly branchValidationCache = new Map<
@@ -337,22 +340,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}; };
} }
async subscribe( registerWorkspace(
params: { cwd: string }, params: { cwd: string },
listener: WorkspaceGitListener, listener: WorkspaceGitListener,
): Promise<{ ): WorkspaceGitSubscription {
initial: WorkspaceGitRuntimeSnapshot;
unsubscribe: () => void;
}> {
const cwd = normalizeWorkspaceId(params.cwd); const cwd = normalizeWorkspaceId(params.cwd);
const target = await this.ensureWorkspaceTarget(cwd); const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener); target.listeners.add(listener);
if (target.listeners.size === 1) { if (target.listeners.size === 1) {
this.startWorkspaceSubscriptionTimers(target); this.startWorkspaceSubscriptionTimers(target);
} }
if (!target.latestSnapshot) {
this.scheduleInitialWorkspaceRefresh(target);
}
this.scheduleWorkspaceObservationSetup(target);
return { return {
initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)),
unsubscribe: () => { unsubscribe: () => {
this.removeWorkspaceListener(cwd, listener); this.removeWorkspaceListener(cwd, listener);
}, },
@@ -365,7 +368,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): Promise<WorkspaceGitRuntimeSnapshot> { ): Promise<WorkspaceGitRuntimeSnapshot> {
cwd = normalizeWorkspaceId(cwd); cwd = normalizeWorkspaceId(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = await this.ensureWorkspaceTarget(cwd); const target = this.ensureWorkspaceTarget(cwd);
if (!request.force && this.isSnapshotWarm(target)) { if (!request.force && this.isSnapshotWarm(target)) {
return target.latestSnapshot; return target.latestSnapshot;
} }
@@ -544,18 +547,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> { async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
cwd = normalizeWorkspaceId(cwd); cwd = normalizeWorkspaceId(cwd);
const target = this.workspaceTargets.get(cwd); const target = this.ensureWorkspaceTarget(cwd);
if (target) { await this.refreshWorkspaceTarget(target, {
await this.refreshWorkspaceTarget(target, { force: false,
force: false, includeGitHub: false,
includeGitHub: false, reason: "refresh",
reason: "refresh", notify: true,
notify: true, });
}); this.scheduleWorkspaceObservationSetup(target);
return;
}
await this.ensureWorkspaceTarget(cwd);
} }
async requestWorkingTreeWatch( async requestWorkingTreeWatch(
@@ -587,7 +586,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.closeWorkspaceTarget(target); this.closeWorkspaceTarget(target);
} }
this.workspaceTargets.clear(); this.workspaceTargets.clear();
this.workspaceTargetSetups.clear();
for (const target of this.repoTargets.values()) { for (const target of this.repoTargets.values()) {
this.closeRepoTarget(target); this.closeRepoTarget(target);
@@ -601,22 +599,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workingTreeWatchSetups.clear(); this.workingTreeWatchSetups.clear();
} }
private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> { private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget {
const existingTarget = this.workspaceTargets.get(cwd); const existingTarget = this.workspaceTargets.get(cwd);
if (existingTarget) { if (existingTarget) {
return existingTarget; return existingTarget;
} }
const existingSetup = this.workspaceTargetSetups.get(cwd); return this.createWorkspaceTarget(cwd);
if (existingSetup) {
return existingSetup;
}
const setup = this.createWorkspaceTarget(cwd).finally(() => {
this.workspaceTargetSetups.delete(cwd);
});
this.workspaceTargetSetups.set(cwd, setup);
return setup;
} }
private readAuxiliaryCache<T>( private readAuxiliaryCache<T>(
@@ -698,7 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return setup; return setup;
} }
private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget {
const target: WorkspaceGitTarget = { const target: WorkspaceGitTarget = {
cwd, cwd,
listeners: new Set(), listeners: new Set(),
@@ -713,35 +702,85 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
latestFingerprint: null, latestFingerprint: null,
lastShellOutAtMs: null, lastShellOutAtMs: null,
repoGitRoot: null, repoGitRoot: null,
observationSetupPromise: null,
observationSetupComplete: false,
closed: false,
}; };
this.workspaceTargets.set(cwd, target); this.workspaceTargets.set(cwd, target);
return target;
}
try { private scheduleInitialWorkspaceRefresh(target: WorkspaceGitTarget): void {
await this.requestWorkspaceSnapshot(target, { queueMicrotask(() => {
if (!this.isActiveObservedWorkspaceTarget(target) || target.latestSnapshot) {
return;
}
void this.refreshWorkspaceTarget(target, {
force: false, force: false,
includeGitHub: true, includeGitHub: true,
reason: "initial", reason: "initial",
notify: false, notify: true,
}); });
});
}
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd); private scheduleWorkspaceObservationSetup(target: WorkspaceGitTarget): void {
if (
target.observationSetupComplete ||
target.observationSetupPromise ||
!this.isActiveObservedWorkspaceTarget(target)
) {
return;
}
target.observationSetupPromise = Promise.resolve()
.then(() => this.setupWorkspaceObservation(target))
.catch((error) => {
this.logger.warn(
{ err: error, cwd: target.cwd },
"Failed to set up workspace git observation",
);
})
.finally(() => {
target.observationSetupPromise = null;
});
}
private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise<void> {
try {
const gitDir = await this.deps.resolveAbsoluteGitDir(target.cwd);
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
if (!gitDir) { if (!gitDir) {
return target; target.observationSetupComplete = true;
return;
} }
const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir); const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir);
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
target.repoGitRoot = repoGitRoot; target.repoGitRoot = repoGitRoot;
this.startWorkspaceWatchers(target, gitDir, repoGitRoot); this.startWorkspaceWatchers(target, gitDir, repoGitRoot);
await this.ensureRepoTarget(target); await this.ensureRepoTarget(target);
return target; if (this.isActiveObservedWorkspaceTarget(target)) {
target.observationSetupComplete = true;
}
} catch (error) { } catch (error) {
this.closeWorkspaceTarget(target);
this.workspaceTargets.delete(cwd);
throw error; throw error;
} }
} }
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return (
!target.closed &&
target.listeners.size > 0 &&
this.workspaceTargets.get(target.cwd) === target
);
}
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> { private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
const repoRoot = await this.resolveCheckoutWatchRoot(cwd); const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: WorkingTreeWatchTarget = { const target: WorkingTreeWatchTarget = {
@@ -862,7 +901,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise<void> { private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise<void> {
const repoGitRoot = workspaceTarget.repoGitRoot; const repoGitRoot = workspaceTarget.repoGitRoot;
if (!repoGitRoot) { if (!repoGitRoot || !this.isActiveObservedWorkspaceTarget(workspaceTarget)) {
return; return;
} }
@@ -873,6 +912,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd); const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd);
if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) {
return;
}
if (!hasOrigin) { if (!hasOrigin) {
return; return;
} }
@@ -904,7 +946,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
typeof targetOrCwd === "string" typeof targetOrCwd === "string"
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd)) ? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
: targetOrCwd; : targetOrCwd;
if (!target) { if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return; return;
} }
@@ -913,6 +955,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
target.debounceTimer = setTimeout(() => { target.debounceTimer = setTimeout(() => {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.debounceTimer = null; target.debounceTimer = null;
void this.refreshWorkspaceTarget(target, { void this.refreshWorkspaceTarget(target, {
force: options?.force === true, force: options?.force === true,
@@ -926,6 +971,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void { private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void {
if (!target.selfHealTimer) { if (!target.selfHealTimer) {
target.selfHealTimer = setInterval(() => { target.selfHealTimer = setInterval(() => {
this.scheduleWorkspaceObservationSetup(target);
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => { this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
this.logger.warn( this.logger.warn(
{ err: error, cwd: target.cwd, reason: "self-heal-git" }, { err: error, cwd: target.cwd, reason: "self-heal-git" },
@@ -965,6 +1011,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: target.cwd, cwd: target.cwd,
headRef, headRef,
onStatus: () => { onStatus: () => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
void this.refreshWorkspaceTarget(target, { void this.refreshWorkspaceTarget(target, {
force: false, force: false,
includeGitHub: true, includeGitHub: true,
@@ -1137,6 +1186,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget, target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest, request: WorkspaceGitRefreshRequest,
): Promise<void> { ): Promise<void> {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
try { try {
await this.requestWorkspaceSnapshot(target, request); await this.requestWorkspaceSnapshot(target, request);
} catch (error) { } catch (error) {
@@ -1397,6 +1449,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
private closeWorkspaceTarget(target: WorkspaceGitTarget): void { private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
target.closed = true;
if (target.debounceTimer) { if (target.debounceTimer) {
clearTimeout(target.debounceTimer); clearTimeout(target.debounceTimer);
target.debounceTimer = null; target.debounceTimer = null;
@@ -6,69 +6,11 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js"; import { createTestLogger } from "../test-utils/test-logger.js";
import { AgentStorage } from "./agent/agent-storage.js"; import { AgentStorage } from "./agent/agent-storage.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
function createNoopWorkspaceGitService(): WorkspaceGitService {
return {
subscribe: async (params) => ({
initial: {
cwd: params.cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getSnapshot: async (cwd) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
resolveRepoRemoteUrl: async () => null,
refresh: async () => {},
requestWorkingTreeWatch: async () => ({
repoRoot: null,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
describe("bootstrapWorkspaceRegistries", () => { describe("bootstrapWorkspaceRegistries", () => {
let tmpDir: string; let tmpDir: string;
let paseoHome: string; let paseoHome: string;