diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index eb8e57ca1..276a2d396 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -2493,7 +2493,7 @@ describe("session checkout refresh handling", () => { messages, }); - await asSessionInternals(session).handleCheckoutRefreshRequest({ + await session.handleMessage({ type: "checkout.refresh.request", cwd: "/tmp/request-worktree", requestId: "request-refresh", @@ -2531,7 +2531,7 @@ describe("session checkout refresh handling", () => { messages, }); - await asSessionInternals(session).handleCheckoutRefreshRequest({ + await session.handleMessage({ type: "checkout.refresh.request", cwd: "/tmp/request-worktree", requestId: "request-refresh-error", @@ -2559,7 +2559,7 @@ describe("session checkout status handling", () => { }; const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutStatusRequest({ + await session.handleMessage({ type: "checkout_status_request", cwd: "/tmp/service-worktree", requestId: "request-status", @@ -2608,7 +2608,7 @@ describe("session checkout status handling", () => { }; const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutStatusRequest({ + await session.handleMessage({ type: "checkout_status_request", cwd: "/tmp/cold-worktree", requestId: "request-cold-status", @@ -2856,7 +2856,7 @@ describe("session branch validation", () => { }; const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleValidateBranchRequest({ + await session.handleMessage({ type: "validate_branch_request", cwd: "/tmp/repo", branchName: "feature", @@ -3279,7 +3279,7 @@ describe("session branch suggestions handling", () => { }; const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleBranchSuggestionsRequest({ + await session.handleMessage({ type: "branch_suggestions_request", cwd: "/tmp/repo", query: "service", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8c7ca3003..86baefd63 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -22,8 +22,6 @@ import { type CheckoutRenameBranchRequest, type StartWorkspaceScriptRequest, type CloseItemsRequest, - type SubscribeCheckoutDiffRequest, - type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, type ProjectPlacementPayload, type WorkspaceSetupSnapshot, @@ -170,6 +168,7 @@ import { import { wrapSpokenInput } from "./voice-config.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { VoiceSession } from "./voice/voice-session.js"; +import { CheckoutSession } from "./session/checkout/checkout-session.js"; import { listDirectoryEntries, readExplorerFile, @@ -206,11 +205,8 @@ import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; import { toCheckoutError } from "./checkout-git-utils.js"; -import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import { - buildCheckoutPrStatusPayloadFromSnapshot, - buildCheckoutStatusPayloadFromSnapshot, -} from "./checkout/status-projection.js"; +import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import { buildCheckoutPrStatusPayloadFromSnapshot } from "./checkout/status-projection.js"; import type { Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; import type pino from "pino"; @@ -711,7 +707,6 @@ export class Session { private readonly chatService: FileBackedChatService; private readonly scheduleService: ScheduleService; private readonly loopService: LoopService; - private readonly checkoutDiffManager: CheckoutDiffManager; private readonly github: GitHubService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext; @@ -750,7 +745,6 @@ export class Session { private readonly terminalController: TerminalSessionController; private inflightRequests = 0; private peakInflightRequests = 0; - private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); private readonly workspaceSetupSnapshots: Map; private readonly workspaceGitFetchSubscriptions = new Map void>(); @@ -758,6 +752,7 @@ export class Session { private readonly fileUploads: FileUploadStore; private readonly workspaceDirectory: WorkspaceDirectory; private readonly voiceSession: VoiceSession; + private readonly checkoutSession: CheckoutSession; private readonly serverId: string | undefined; private readonly daemonVersion: string | undefined; private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"]; @@ -839,11 +834,19 @@ export class Session { this.chatService = chatService; this.scheduleService = scheduleService; this.loopService = loopService; - this.checkoutDiffManager = checkoutDiffManager; this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext; this.workspaceGitService = workspaceGitService; + this.checkoutSession = new CheckoutSession({ + host: { + emit: (msg) => this.emit(msg), + }, + workspaceGitService: this.workspaceGitService, + github: this.github, + checkoutDiffManager, + logger: this.sessionLogger, + }); this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl ?? null; this.terminalManager = terminalManager; @@ -2007,17 +2010,17 @@ export class Session { private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "checkout_status_request": - return this.handleCheckoutStatusRequest(msg); + return this.checkoutSession.handleStatusRequest(msg); case "validate_branch_request": - return this.handleValidateBranchRequest(msg); + return this.checkoutSession.handleValidateBranchRequest(msg); case "branch_suggestions_request": - return this.handleBranchSuggestionsRequest(msg); + return this.checkoutSession.handleBranchSuggestionsRequest(msg); case "directory_suggestions_request": return this.handleDirectorySuggestionsRequest(msg); case "subscribe_checkout_diff_request": - return this.handleSubscribeCheckoutDiffRequest(msg); + return this.checkoutSession.handleSubscribeDiffRequest(msg); case "unsubscribe_checkout_diff_request": - this.handleUnsubscribeCheckoutDiffRequest(msg); + this.checkoutSession.handleUnsubscribeDiffRequest(msg); return undefined; case "checkout_switch_branch_request": return this.handleCheckoutSwitchBranchRequest(msg); @@ -2034,7 +2037,7 @@ export class Session { case "checkout_push_request": return this.handleCheckoutPushRequest(msg); case "checkout.refresh.request": - return this.handleCheckoutRefreshRequest(msg); + return this.checkoutSession.handleRefreshRequest(msg); case "checkout_pr_create_request": return this.handleCheckoutPrCreateRequest(msg); case "checkout_pr_merge_request": @@ -4520,144 +4523,6 @@ export class Session { } } - private async handleCheckoutStatusRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - const resolvedCwd = expandTilde(cwd); - - try { - const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd); - this.emit({ - type: "checkout_status_response", - payload: buildCheckoutStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), - }); - } catch (error) { - this.emit({ - type: "checkout_status_response", - payload: { - cwd, - isGit: false, - repoRoot: null, - currentBranch: null, - isDirty: null, - baseRef: null, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - hasRemote: false, - remoteUrl: null, - isPaseoOwnedWorktree: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleValidateBranchRequest( - msg: Extract, - ): Promise { - const { cwd, branchName, requestId } = msg; - - try { - const resolvedCwd = expandTilde(cwd); - this.assertSafeGitRef(branchName, "branch"); - - const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName); - switch (resolution.kind) { - case "local": - this.emit({ - type: "validate_branch_response", - payload: { - exists: true, - resolvedRef: resolution.name, - isRemote: false, - error: null, - requestId, - }, - }); - return; - case "remote-only": - this.emit({ - type: "validate_branch_response", - payload: { - exists: true, - resolvedRef: resolution.remoteRef, - isRemote: true, - error: null, - requestId, - }, - }); - return; - case "not-found": - this.emit({ - type: "validate_branch_response", - payload: { - exists: false, - resolvedRef: null, - isRemote: false, - error: null, - requestId, - }, - }); - return; - default: { - const exhaustiveCheck: never = resolution; - throw new Error(`Unhandled branch resolution: ${getErrorMessage(exhaustiveCheck)}`); - } - } - } catch (error) { - this.emit({ - type: "validate_branch_response", - payload: { - exists: false, - resolvedRef: null, - isRemote: false, - error: error instanceof Error ? error.message : String(error), - requestId, - }, - }); - } - } - - private async handleBranchSuggestionsRequest( - msg: Extract, - ): Promise { - const { cwd, query, limit, requestId } = msg; - - try { - const resolvedCwd = expandTilde(cwd); - const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { - query, - limit, - }); - this.emit({ - type: "branch_suggestions_response", - payload: { - branches: branchDetails.map((branch) => branch.name), - branchDetails, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "branch_suggestions_response", - payload: { - branches: [], - branchDetails: [], - error: error instanceof Error ? error.message : String(error), - requestId, - }, - }); - } - } - private async handleGitHubSearchRequest( msg: Extract, ): Promise { @@ -4885,73 +4750,12 @@ export class Session { "Failed to emit workspace update after git branch snapshot", ); }); - this.emitCheckoutStatusUpdate(normalizedCwd, snapshot); + this.checkoutSession.emitStatusUpdate(normalizedCwd, snapshot); }, ); this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe); } - private async handleSubscribeCheckoutDiffRequest( - msg: SubscribeCheckoutDiffRequest, - ): Promise { - const cwd = expandTilde(msg.cwd); - this.checkoutDiffSubscriptions.get(msg.subscriptionId)?.(); - this.checkoutDiffSubscriptions.delete(msg.subscriptionId); - const subscription = await this.checkoutDiffManager.subscribe( - { cwd, compare: msg.compare }, - (snapshot) => { - this.emit({ - type: "checkout_diff_update", - payload: { - subscriptionId: msg.subscriptionId, - ...snapshot, - }, - }); - }, - ); - this.checkoutDiffSubscriptions.set(msg.subscriptionId, subscription.unsubscribe); - - this.emit({ - type: "subscribe_checkout_diff_response", - payload: { - subscriptionId: msg.subscriptionId, - ...subscription.initial, - requestId: msg.requestId, - }, - }); - } - - private handleUnsubscribeCheckoutDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void { - this.checkoutDiffSubscriptions.get(msg.subscriptionId)?.(); - this.checkoutDiffSubscriptions.delete(msg.subscriptionId); - } - - private emitCheckoutStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { - try { - const requestId = `subscription:${cwd}`; - this.emit({ - type: "checkout_status_update", - payload: { - ...buildCheckoutStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), - prStatus: buildCheckoutPrStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), - }, - }); - } catch (error) { - this.sessionLogger.warn( - { err: error, cwd }, - "Failed to emit workspace checkout status update", - ); - } - } - private async handleCheckoutSwitchBranchRequest( msg: Extract, ): Promise { @@ -4959,7 +4763,7 @@ export class Session { try { const checkoutResult = await this.checkoutExistingBranch(cwd, branch); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. @@ -5011,7 +4815,7 @@ export class Session { try { const result = await this.renameCurrentBranch(cwd, branch); await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); // Branch is a git fact derived per-descriptor from each workspace's own @@ -5066,7 +4870,7 @@ export class Session { cwd, }); await this.notifyGitMutation(cwd, "stash-push"); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); this.emit({ type: "stash_save_response", payload: { cwd, success: true, error: null, requestId }, @@ -5088,7 +4892,7 @@ export class Session { cwd, }); await this.notifyGitMutation(cwd, "stash-pop"); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); this.emit({ type: "stash_pop_response", payload: { cwd, success: true, error: null, requestId }, @@ -5140,7 +4944,7 @@ export class Session { addAll: msg.addAll ?? true, }); await this.notifyGitMutation(cwd, "commit-changes"); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); this.emit({ type: "checkout_commit_response", @@ -5201,7 +5005,7 @@ export class Session { this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }), ...(mutatedCwd !== cwd ? [this.notifyGitMutation(cwd, "merge-to-base")] : []), ]); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); this.emit({ type: "checkout_merge_response", @@ -5243,7 +5047,7 @@ export class Session { requireCleanTarget: msg.requireCleanTarget ?? true, }); await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true }); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); this.emit({ type: "checkout_merge_from_base_response", @@ -5275,7 +5079,7 @@ export class Session { try { await pullCurrentBranch(cwd); await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true }); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.checkoutSession.scheduleDiffRefresh(cwd); this.emit({ type: "checkout_pull_response", @@ -5329,41 +5133,6 @@ export class Session { } } - private async handleCheckoutRefreshRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - this.github.invalidate({ cwd }); - await this.workspaceGitService.getSnapshot(cwd, { - force: true, - includeGitHub: true, - reason: "manual-refresh", - }); - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); - this.emit({ - type: "checkout.refresh.response", - payload: { - cwd, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout.refresh.response", - payload: { - cwd, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - private async handleCheckoutPrCreateRequest( msg: Extract, ): Promise { @@ -8683,10 +8452,7 @@ export class Session { this.terminalController.dispose(); - for (const unsubscribe of this.checkoutDiffSubscriptions.values()) { - unsubscribe(); - } - this.checkoutDiffSubscriptions.clear(); + this.checkoutSession.cleanup(); for (const unsubscribe of this.workspaceGitSubscriptions.values()) { unsubscribe(); diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts new file mode 100644 index 000000000..11a8bb52f --- /dev/null +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it } from "vitest"; +import pino from "pino"; +import { + type CheckoutDiffSubscriber, + CheckoutSession, + type CheckoutSessionHost, +} from "./checkout-session.js"; +import { createGitHubService } from "../../../services/github-service.js"; +import type { SessionOutboundMessage } from "../../messages.js"; +import type { + CheckoutDiffCompareInput, + CheckoutDiffSnapshotPayload, +} from "../../checkout-diff-manager.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, +} from "../../workspace-git-service.js"; +import { + createNoGitWorkspaceRuntimeSnapshot, + createNoopWorkspaceGitService, +} from "../../test-utils/workspace-git-service-stub.js"; +import { expandTilde } from "../../../utils/path.js"; + +interface FakeDiffSubscription { + cwd: string; + compare: CheckoutDiffCompareInput; + listener: (snapshot: CheckoutDiffSnapshotPayload) => void; + unsubscribeCalls: number; +} + +function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { + const subscriptions: FakeDiffSubscription[] = []; + const refreshedCwds: string[] = []; + const subscriber: CheckoutDiffSubscriber = { + subscribe: async (params, listener) => { + const subscription: FakeDiffSubscription = { + cwd: params.cwd, + compare: params.compare, + listener, + unsubscribeCalls: 0, + }; + subscriptions.push(subscription); + return { + initial: { ...initial, cwd: params.cwd }, + unsubscribe: () => { + subscription.unsubscribeCalls += 1; + }, + }; + }, + scheduleRefreshForCwd: (cwd) => { + refreshedCwds.push(cwd); + }, + }; + return { subscriber, subscriptions, refreshedCwds }; +} + +function makeCheckoutSession(options?: { + git?: Partial; + diff?: CheckoutDiffSubscriber; +}) { + const emitted: SessionOutboundMessage[] = []; + const host: CheckoutSessionHost = { emit: (msg) => emitted.push(msg) }; + const checkout = new CheckoutSession({ + host, + workspaceGitService: createNoopWorkspaceGitService(options?.git), + github: createGitHubService(), + checkoutDiffManager: + options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber, + logger: pino({ level: "silent" }), + }); + return { checkout, emitted }; +} + +function createGitSnapshot(cwd: string, currentBranch: string): WorkspaceGitRuntimeSnapshot { + return { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: cwd, + currentBranch, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: false, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + diffStat: null, + }, + github: { featuresEnabled: false, pullRequest: null, error: null }, + }; +} + +describe("CheckoutSession", () => { + describe("status", () => { + it("emits a checkout status response built from the git snapshot", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { getSnapshot: async () => createGitSnapshot("/repo", "main") }, + }); + + await checkout.handleStatusRequest({ + type: "checkout_status_request", + cwd: "/repo", + requestId: "r1", + }); + + expect(emitted).toEqual([ + { + type: "checkout_status_response", + payload: expect.objectContaining({ + cwd: "/repo", + requestId: "r1", + isGit: true, + currentBranch: "main", + }), + }, + ]); + }); + + it("emits an error status response when the git snapshot read fails", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { + getSnapshot: async () => { + throw new Error("boom"); + }, + }, + }); + + await checkout.handleStatusRequest({ + type: "checkout_status_request", + cwd: "/repo", + requestId: "r2", + }); + + expect(emitted).toEqual([ + { + type: "checkout_status_response", + payload: expect.objectContaining({ + cwd: "/repo", + requestId: "r2", + isGit: false, + error: { code: "UNKNOWN", message: "boom" }, + }), + }, + ]); + }); + }); + + describe("validate branch", () => { + it("validates an existing local branch", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { validateBranchRef: async () => ({ kind: "local", name: "feature" }) }, + }); + + await checkout.handleValidateBranchRequest({ + type: "validate_branch_request", + cwd: "/repo", + branchName: "feature", + requestId: "r3", + }); + + expect(emitted).toEqual([ + { + type: "validate_branch_response", + payload: { + exists: true, + resolvedRef: "feature", + isRemote: false, + error: null, + requestId: "r3", + }, + }, + ]); + }); + + it("reports a missing branch as not found", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { validateBranchRef: async () => ({ kind: "not-found" }) }, + }); + + await checkout.handleValidateBranchRequest({ + type: "validate_branch_request", + cwd: "/repo", + branchName: "ghost", + requestId: "r4", + }); + + expect(emitted).toEqual([ + { + type: "validate_branch_response", + payload: { + exists: false, + resolvedRef: null, + isRemote: false, + error: null, + requestId: "r4", + }, + }, + ]); + }); + + it("rejects an unsafe branch ref before touching git", async () => { + let validateCalls = 0; + const { checkout, emitted } = makeCheckoutSession({ + git: { + validateBranchRef: async () => { + validateCalls += 1; + return { kind: "not-found" }; + }, + }, + }); + + await checkout.handleValidateBranchRequest({ + type: "validate_branch_request", + cwd: "/repo", + branchName: "bad ref!", + requestId: "r5", + }); + + expect(validateCalls).toBe(0); + expect(emitted).toEqual([ + { + type: "validate_branch_response", + payload: { + exists: false, + resolvedRef: null, + isRemote: false, + error: "Invalid branch: bad ref!", + requestId: "r5", + }, + }, + ]); + }); + }); + + describe("branch suggestions", () => { + it("emits branch names and details", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { + suggestBranchesForCwd: async () => [ + { name: "main", committerDate: 1, hasLocal: true, hasRemote: true }, + { name: "dev", committerDate: 2, hasLocal: true, hasRemote: false }, + ], + }, + }); + + await checkout.handleBranchSuggestionsRequest({ + type: "branch_suggestions_request", + cwd: "/repo", + requestId: "r6", + }); + + expect(emitted).toEqual([ + { + type: "branch_suggestions_response", + payload: { + branches: ["main", "dev"], + branchDetails: [ + { name: "main", committerDate: 1, hasLocal: true, hasRemote: true }, + { name: "dev", committerDate: 2, hasLocal: true, hasRemote: false }, + ], + error: null, + requestId: "r6", + }, + }, + ]); + }); + }); + + describe("refresh", () => { + it("forces a github-inclusive snapshot, nudges diffs, and confirms success", async () => { + const snapshotCalls: Array<{ cwd: string; options: unknown }> = []; + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout, emitted } = makeCheckoutSession({ + git: { + getSnapshot: async (cwd, snapshotOptions) => { + snapshotCalls.push({ cwd, options: snapshotOptions }); + return createNoGitWorkspaceRuntimeSnapshot(cwd); + }, + }, + diff: subscriber, + }); + + await checkout.handleRefreshRequest({ + type: "checkout.refresh.request", + cwd: "/repo", + requestId: "r7", + }); + + expect(snapshotCalls).toEqual([ + { cwd: "/repo", options: { force: true, includeGitHub: true, reason: "manual-refresh" } }, + ]); + expect(refreshedCwds).toEqual(["/repo"]); + expect(emitted).toEqual([ + { + type: "checkout.refresh.response", + payload: { cwd: "/repo", success: true, error: null, requestId: "r7" }, + }, + ]); + }); + + it("expands a tilde cwd before refreshing git and diffs", async () => { + const snapshotCalls: string[] = []; + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout } = makeCheckoutSession({ + git: { + getSnapshot: async (cwd) => { + snapshotCalls.push(cwd); + return createNoGitWorkspaceRuntimeSnapshot(cwd); + }, + }, + diff: subscriber, + }); + + await checkout.handleRefreshRequest({ + type: "checkout.refresh.request", + cwd: "~/repo", + requestId: "r-tilde", + }); + + const resolvedCwd = expandTilde("~/repo"); + expect(snapshotCalls).toEqual([resolvedCwd]); + expect(refreshedCwds).toEqual([resolvedCwd]); + }); + }); + + describe("diff subscriptions", () => { + it("opens a subscription, streams updates tagged with the id, and tears down on unsubscribe", async () => { + const { subscriber, subscriptions } = createFakeDiffSubscriber({ + cwd: "/repo", + files: [], + error: null, + }); + const { checkout, emitted } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleSubscribeDiffRequest({ + type: "subscribe_checkout_diff_request", + subscriptionId: "s1", + cwd: "/repo", + compare: { mode: "uncommitted" }, + requestId: "r8", + }); + + expect(emitted).toEqual([ + { + type: "subscribe_checkout_diff_response", + payload: { subscriptionId: "s1", cwd: "/repo", files: [], error: null, requestId: "r8" }, + }, + ]); + expect(subscriptions).toHaveLength(1); + + subscriptions[0].listener({ + cwd: "/repo", + files: [], + error: { code: "UNKNOWN", message: "transient" }, + }); + + expect(emitted[1]).toEqual({ + type: "checkout_diff_update", + payload: { + subscriptionId: "s1", + cwd: "/repo", + files: [], + error: { code: "UNKNOWN", message: "transient" }, + }, + }); + + checkout.handleUnsubscribeDiffRequest({ + type: "unsubscribe_checkout_diff_request", + subscriptionId: "s1", + }); + + expect(subscriptions[0].unsubscribeCalls).toBe(1); + }); + + it("replaces an existing subscription when the same id subscribes again", async () => { + const { subscriber, subscriptions } = createFakeDiffSubscriber({ + cwd: "/repo", + files: [], + error: null, + }); + const { checkout } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleSubscribeDiffRequest({ + type: "subscribe_checkout_diff_request", + subscriptionId: "s1", + cwd: "/repo", + compare: { mode: "uncommitted" }, + requestId: "first", + }); + await checkout.handleSubscribeDiffRequest({ + type: "subscribe_checkout_diff_request", + subscriptionId: "s1", + cwd: "/repo", + compare: { mode: "uncommitted" }, + requestId: "second", + }); + + expect(subscriptions).toHaveLength(2); + expect(subscriptions[0].unsubscribeCalls).toBe(1); + expect(subscriptions[1].unsubscribeCalls).toBe(0); + }); + + it("unsubscribes every live subscription on cleanup", async () => { + const { subscriber, subscriptions } = createFakeDiffSubscriber({ + cwd: "/repo", + files: [], + error: null, + }); + const { checkout } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleSubscribeDiffRequest({ + type: "subscribe_checkout_diff_request", + subscriptionId: "s1", + cwd: "/repo", + compare: { mode: "uncommitted" }, + requestId: "r", + }); + await checkout.handleSubscribeDiffRequest({ + type: "subscribe_checkout_diff_request", + subscriptionId: "s2", + cwd: "/repo", + compare: { mode: "uncommitted" }, + requestId: "r", + }); + + checkout.cleanup(); + + expect(subscriptions[0].unsubscribeCalls).toBe(1); + expect(subscriptions[1].unsubscribeCalls).toBe(1); + }); + }); + + describe("status updates", () => { + it("emits a checkout status update for a workspace git snapshot", () => { + const { checkout, emitted } = makeCheckoutSession(); + + checkout.emitStatusUpdate("/repo", createGitSnapshot("/repo", "main")); + + expect(emitted).toEqual([ + { + type: "checkout_status_update", + payload: expect.objectContaining({ cwd: "/repo", currentBranch: "main" }), + }, + ]); + }); + }); +}); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts new file mode 100644 index 000000000..058bcfef6 --- /dev/null +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -0,0 +1,317 @@ +import type pino from "pino"; +import { getErrorMessage } from "@getpaseo/protocol/error-utils"; +import type { + BranchSuggestionsRequest, + CheckoutRefreshRequest, + CheckoutStatusRequest, + SessionOutboundMessage, + SubscribeCheckoutDiffRequest, + UnsubscribeCheckoutDiffRequest, + ValidateBranchRequest, +} from "../../messages.js"; +import type { + CheckoutDiffCompareInput, + CheckoutDiffSnapshotPayload, +} from "../../checkout-diff-manager.js"; +import { toCheckoutError } from "../../checkout-git-utils.js"; +import { + buildCheckoutPrStatusPayloadFromSnapshot, + buildCheckoutStatusPayloadFromSnapshot, +} from "../../checkout/status-projection.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, +} from "../../workspace-git-service.js"; +import { assertSafeGitRef } from "../../worktree-session.js"; +import type { GitHubService } from "../../../services/github-service.js"; +import { expandTilde } from "../../../utils/path.js"; + +export interface CheckoutSessionHost { + emit(msg: SessionOutboundMessage): void; +} + +/** + * The slice of CheckoutDiffManager that CheckoutSession needs: open a live diff + * subscription, and nudge open subscriptions to recompute after a mutation. The + * real CheckoutDiffManager satisfies this structurally; tests supply a fake. + */ +export interface CheckoutDiffSubscriber { + subscribe( + params: { cwd: string; compare: CheckoutDiffCompareInput }, + listener: (snapshot: CheckoutDiffSnapshotPayload) => void, + ): Promise<{ initial: CheckoutDiffSnapshotPayload; unsubscribe: () => void }>; + scheduleRefreshForCwd(cwd: string): void; +} + +export interface CheckoutSessionOptions { + host: CheckoutSessionHost; + workspaceGitService: WorkspaceGitService; + github: GitHubService; + checkoutDiffManager: CheckoutDiffSubscriber; + logger: pino.Logger; +} + +/** + * The read & live-stream side of a client's checkout view: status queries, + * branch validation/suggestions, manual refresh, and the live git-diff and + * checkout-status subscriptions. + * + * The command operations (switch/rename/commit/merge/pull/push/stash) and the + * GitHub-PR operations still live on Session; they keep the diff in sync by + * calling scheduleDiffRefresh(), and the workspace git observer streams branch + * changes through emitStatusUpdate(). + */ +export class CheckoutSession { + private readonly host: CheckoutSessionHost; + private readonly workspaceGitService: WorkspaceGitService; + private readonly github: GitHubService; + private readonly checkoutDiffManager: CheckoutDiffSubscriber; + private readonly logger: pino.Logger; + private readonly diffSubscriptions = new Map void>(); + + constructor(options: CheckoutSessionOptions) { + this.host = options.host; + this.workspaceGitService = options.workspaceGitService; + this.github = options.github; + this.checkoutDiffManager = options.checkoutDiffManager; + this.logger = options.logger; + } + + async handleStatusRequest(msg: CheckoutStatusRequest): Promise { + const { cwd, requestId } = msg; + const resolvedCwd = expandTilde(cwd); + + try { + const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd); + this.host.emit({ + type: "checkout_status_response", + payload: buildCheckoutStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + }); + } catch (error) { + this.host.emit({ + type: "checkout_status_response", + payload: { + cwd, + isGit: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + isPaseoOwnedWorktree: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleValidateBranchRequest(msg: ValidateBranchRequest): Promise { + const { cwd, branchName, requestId } = msg; + + try { + const resolvedCwd = expandTilde(cwd); + assertSafeGitRef(branchName, "branch"); + + const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName); + switch (resolution.kind) { + case "local": + this.host.emit({ + type: "validate_branch_response", + payload: { + exists: true, + resolvedRef: resolution.name, + isRemote: false, + error: null, + requestId, + }, + }); + return; + case "remote-only": + this.host.emit({ + type: "validate_branch_response", + payload: { + exists: true, + resolvedRef: resolution.remoteRef, + isRemote: true, + error: null, + requestId, + }, + }); + return; + case "not-found": + this.host.emit({ + type: "validate_branch_response", + payload: { + exists: false, + resolvedRef: null, + isRemote: false, + error: null, + requestId, + }, + }); + return; + default: { + const exhaustiveCheck: never = resolution; + throw new Error(`Unhandled branch resolution: ${getErrorMessage(exhaustiveCheck)}`); + } + } + } catch (error) { + this.host.emit({ + type: "validate_branch_response", + payload: { + exists: false, + resolvedRef: null, + isRemote: false, + error: error instanceof Error ? error.message : String(error), + requestId, + }, + }); + } + } + + async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise { + const { cwd, query, limit, requestId } = msg; + + try { + const resolvedCwd = expandTilde(cwd); + const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { + query, + limit, + }); + this.host.emit({ + type: "branch_suggestions_response", + payload: { + branches: branchDetails.map((branch) => branch.name), + branchDetails, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "branch_suggestions_response", + payload: { + branches: [], + branchDetails: [], + error: error instanceof Error ? error.message : String(error), + requestId, + }, + }); + } + } + + async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise { + const cwd = expandTilde(msg.cwd); + this.diffSubscriptions.get(msg.subscriptionId)?.(); + this.diffSubscriptions.delete(msg.subscriptionId); + const subscription = await this.checkoutDiffManager.subscribe( + { cwd, compare: msg.compare }, + (snapshot) => { + this.host.emit({ + type: "checkout_diff_update", + payload: { + subscriptionId: msg.subscriptionId, + ...snapshot, + }, + }); + }, + ); + this.diffSubscriptions.set(msg.subscriptionId, subscription.unsubscribe); + + this.host.emit({ + type: "subscribe_checkout_diff_response", + payload: { + subscriptionId: msg.subscriptionId, + ...subscription.initial, + requestId: msg.requestId, + }, + }); + } + + handleUnsubscribeDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void { + this.diffSubscriptions.get(msg.subscriptionId)?.(); + this.diffSubscriptions.delete(msg.subscriptionId); + } + + async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise { + const { cwd, requestId } = msg; + const resolvedCwd = expandTilde(cwd); + + try { + this.github.invalidate({ cwd: resolvedCwd }); + await this.workspaceGitService.getSnapshot(resolvedCwd, { + force: true, + includeGitHub: true, + reason: "manual-refresh", + }); + this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); + this.host.emit({ + type: "checkout.refresh.response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.refresh.response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { + try { + const requestId = `subscription:${cwd}`; + this.host.emit({ + type: "checkout_status_update", + payload: { + ...buildCheckoutStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + prStatus: buildCheckoutPrStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + }, + }); + } catch (error) { + this.logger.warn({ err: error, cwd }, "Failed to emit workspace checkout status update"); + } + } + + /** + * Notify the live diff subscriptions that the working tree at `cwd` changed. + * Called by the command handlers that still live on Session after they mutate + * the repository. + */ + scheduleDiffRefresh(cwd: string): void { + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + } + + cleanup(): void { + for (const unsubscribe of this.diffSubscriptions.values()) { + unsubscribe(); + } + this.diffSubscriptions.clear(); + } +}