refactor(server): extract checkout read subsystem into CheckoutSession (#1644)

Extract the checkout read & live-stream side (status, branch validate/suggest, manual refresh, diff + status subscriptions) out of session.ts into session/checkout/checkout-session.ts behind a narrow CheckoutSessionHost seam and a CheckoutDiffSubscriber port. session.ts -234 net lines; 12 unit tests via injected fakes (no module mocks); 6 session tests moved onto the public handleMessage path. Also fixes a pre-existing tilde-resolution bug in the refresh handler (TDD).
This commit is contained in:
Mohamed Boudra
2026-06-21 19:37:48 +08:00
committed by GitHub
parent cf4dd8616c
commit 18f880e561
4 changed files with 810 additions and 269 deletions
+6 -6
View File
@@ -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",
+29 -263
View File
@@ -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<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
private readonly workspaceGitFetchSubscriptions = new Map<string, () => 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<void> | 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<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "validate_branch_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "branch_suggestions_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "github_search_request" }>,
): Promise<void> {
@@ -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<void> {
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<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
@@ -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<SessionInboundMessage, { type: "checkout.refresh.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
): Promise<void> {
@@ -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();
@@ -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<WorkspaceGitService>;
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" }),
},
]);
});
});
});
@@ -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<string, () => 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
}