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:
@@ -2493,7 +2493,7 @@ describe("session checkout refresh handling", () => {
|
|||||||
messages,
|
messages,
|
||||||
});
|
});
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutRefreshRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.refresh.request",
|
type: "checkout.refresh.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
requestId: "request-refresh",
|
requestId: "request-refresh",
|
||||||
@@ -2531,7 +2531,7 @@ describe("session checkout refresh handling", () => {
|
|||||||
messages,
|
messages,
|
||||||
});
|
});
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutRefreshRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.refresh.request",
|
type: "checkout.refresh.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
requestId: "request-refresh-error",
|
requestId: "request-refresh-error",
|
||||||
@@ -2559,7 +2559,7 @@ describe("session checkout status handling", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutStatusRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_status_request",
|
type: "checkout_status_request",
|
||||||
cwd: "/tmp/service-worktree",
|
cwd: "/tmp/service-worktree",
|
||||||
requestId: "request-status",
|
requestId: "request-status",
|
||||||
@@ -2608,7 +2608,7 @@ describe("session checkout status handling", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutStatusRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_status_request",
|
type: "checkout_status_request",
|
||||||
cwd: "/tmp/cold-worktree",
|
cwd: "/tmp/cold-worktree",
|
||||||
requestId: "request-cold-status",
|
requestId: "request-cold-status",
|
||||||
@@ -2856,7 +2856,7 @@ describe("session branch validation", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleValidateBranchRequest({
|
await session.handleMessage({
|
||||||
type: "validate_branch_request",
|
type: "validate_branch_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
branchName: "feature",
|
branchName: "feature",
|
||||||
@@ -3279,7 +3279,7 @@ describe("session branch suggestions handling", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleBranchSuggestionsRequest({
|
await session.handleMessage({
|
||||||
type: "branch_suggestions_request",
|
type: "branch_suggestions_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
query: "service",
|
query: "service",
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import {
|
|||||||
type CheckoutRenameBranchRequest,
|
type CheckoutRenameBranchRequest,
|
||||||
type StartWorkspaceScriptRequest,
|
type StartWorkspaceScriptRequest,
|
||||||
type CloseItemsRequest,
|
type CloseItemsRequest,
|
||||||
type SubscribeCheckoutDiffRequest,
|
|
||||||
type UnsubscribeCheckoutDiffRequest,
|
|
||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
type WorkspaceSetupSnapshot,
|
type WorkspaceSetupSnapshot,
|
||||||
@@ -170,6 +168,7 @@ import {
|
|||||||
import { wrapSpokenInput } from "./voice-config.js";
|
import { wrapSpokenInput } from "./voice-config.js";
|
||||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||||
import { VoiceSession } from "./voice/voice-session.js";
|
import { VoiceSession } from "./voice/voice-session.js";
|
||||||
|
import { CheckoutSession } from "./session/checkout/checkout-session.js";
|
||||||
import {
|
import {
|
||||||
listDirectoryEntries,
|
listDirectoryEntries,
|
||||||
readExplorerFile,
|
readExplorerFile,
|
||||||
@@ -206,11 +205,8 @@ import { getProjectIcon } from "../utils/project-icon.js";
|
|||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||||
import { toCheckoutError } from "./checkout-git-utils.js";
|
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
import {
|
import { buildCheckoutPrStatusPayloadFromSnapshot } from "./checkout/status-projection.js";
|
||||||
buildCheckoutPrStatusPayloadFromSnapshot,
|
|
||||||
buildCheckoutStatusPayloadFromSnapshot,
|
|
||||||
} from "./checkout/status-projection.js";
|
|
||||||
import type { Resolvable } from "./speech/provider-resolver.js";
|
import type { Resolvable } from "./speech/provider-resolver.js";
|
||||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -711,7 +707,6 @@ export class Session {
|
|||||||
private readonly chatService: FileBackedChatService;
|
private readonly chatService: FileBackedChatService;
|
||||||
private readonly scheduleService: ScheduleService;
|
private readonly scheduleService: ScheduleService;
|
||||||
private readonly loopService: LoopService;
|
private readonly loopService: LoopService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
|
||||||
private readonly github: GitHubService;
|
private readonly github: GitHubService;
|
||||||
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
||||||
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
|
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
|
||||||
@@ -750,7 +745,6 @@ export class Session {
|
|||||||
private readonly terminalController: TerminalSessionController;
|
private readonly terminalController: TerminalSessionController;
|
||||||
private inflightRequests = 0;
|
private inflightRequests = 0;
|
||||||
private peakInflightRequests = 0;
|
private peakInflightRequests = 0;
|
||||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
|
||||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||||
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
||||||
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||||
@@ -758,6 +752,7 @@ export class Session {
|
|||||||
private readonly fileUploads: FileUploadStore;
|
private readonly fileUploads: FileUploadStore;
|
||||||
private readonly workspaceDirectory: WorkspaceDirectory;
|
private readonly workspaceDirectory: WorkspaceDirectory;
|
||||||
private readonly voiceSession: VoiceSession;
|
private readonly voiceSession: VoiceSession;
|
||||||
|
private readonly checkoutSession: CheckoutSession;
|
||||||
private readonly serverId: string | undefined;
|
private readonly serverId: string | undefined;
|
||||||
private readonly daemonVersion: string | undefined;
|
private readonly daemonVersion: string | undefined;
|
||||||
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
|
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
|
||||||
@@ -839,11 +834,19 @@ export class Session {
|
|||||||
this.chatService = chatService;
|
this.chatService = chatService;
|
||||||
this.scheduleService = scheduleService;
|
this.scheduleService = scheduleService;
|
||||||
this.loopService = loopService;
|
this.loopService = loopService;
|
||||||
this.checkoutDiffManager = checkoutDiffManager;
|
|
||||||
this.github = github ?? createGitHubService();
|
this.github = github ?? createGitHubService();
|
||||||
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
||||||
this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
|
this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
|
||||||
this.workspaceGitService = workspaceGitService;
|
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.daemonConfigStore = daemonConfigStore;
|
||||||
this.mcpBaseUrl = mcpBaseUrl ?? null;
|
this.mcpBaseUrl = mcpBaseUrl ?? null;
|
||||||
this.terminalManager = terminalManager;
|
this.terminalManager = terminalManager;
|
||||||
@@ -2007,17 +2010,17 @@ export class Session {
|
|||||||
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "checkout_status_request":
|
case "checkout_status_request":
|
||||||
return this.handleCheckoutStatusRequest(msg);
|
return this.checkoutSession.handleStatusRequest(msg);
|
||||||
case "validate_branch_request":
|
case "validate_branch_request":
|
||||||
return this.handleValidateBranchRequest(msg);
|
return this.checkoutSession.handleValidateBranchRequest(msg);
|
||||||
case "branch_suggestions_request":
|
case "branch_suggestions_request":
|
||||||
return this.handleBranchSuggestionsRequest(msg);
|
return this.checkoutSession.handleBranchSuggestionsRequest(msg);
|
||||||
case "directory_suggestions_request":
|
case "directory_suggestions_request":
|
||||||
return this.handleDirectorySuggestionsRequest(msg);
|
return this.handleDirectorySuggestionsRequest(msg);
|
||||||
case "subscribe_checkout_diff_request":
|
case "subscribe_checkout_diff_request":
|
||||||
return this.handleSubscribeCheckoutDiffRequest(msg);
|
return this.checkoutSession.handleSubscribeDiffRequest(msg);
|
||||||
case "unsubscribe_checkout_diff_request":
|
case "unsubscribe_checkout_diff_request":
|
||||||
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
this.checkoutSession.handleUnsubscribeDiffRequest(msg);
|
||||||
return undefined;
|
return undefined;
|
||||||
case "checkout_switch_branch_request":
|
case "checkout_switch_branch_request":
|
||||||
return this.handleCheckoutSwitchBranchRequest(msg);
|
return this.handleCheckoutSwitchBranchRequest(msg);
|
||||||
@@ -2034,7 +2037,7 @@ export class Session {
|
|||||||
case "checkout_push_request":
|
case "checkout_push_request":
|
||||||
return this.handleCheckoutPushRequest(msg);
|
return this.handleCheckoutPushRequest(msg);
|
||||||
case "checkout.refresh.request":
|
case "checkout.refresh.request":
|
||||||
return this.handleCheckoutRefreshRequest(msg);
|
return this.checkoutSession.handleRefreshRequest(msg);
|
||||||
case "checkout_pr_create_request":
|
case "checkout_pr_create_request":
|
||||||
return this.handleCheckoutPrCreateRequest(msg);
|
return this.handleCheckoutPrCreateRequest(msg);
|
||||||
case "checkout_pr_merge_request":
|
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(
|
private async handleGitHubSearchRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "github_search_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "github_search_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -4885,73 +4750,12 @@ export class Session {
|
|||||||
"Failed to emit workspace update after git branch snapshot",
|
"Failed to emit workspace update after git branch snapshot",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
|
this.checkoutSession.emitStatusUpdate(normalizedCwd, snapshot);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe);
|
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(
|
private async handleCheckoutSwitchBranchRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -4959,7 +4763,7 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const checkoutResult = await this.checkoutExistingBranch(cwd, branch);
|
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
|
// Push a workspace_update immediately so the sidebar/header reflect
|
||||||
// the new branch name without waiting for the background git watcher.
|
// the new branch name without waiting for the background git watcher.
|
||||||
@@ -5011,7 +4815,7 @@ export class Session {
|
|||||||
try {
|
try {
|
||||||
const result = await this.renameCurrentBranch(cwd, branch);
|
const result = await this.renameCurrentBranch(cwd, branch);
|
||||||
await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
|
await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutSession.scheduleDiffRefresh(cwd);
|
||||||
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
||||||
|
|
||||||
// Branch is a git fact derived per-descriptor from each workspace's own
|
// Branch is a git fact derived per-descriptor from each workspace's own
|
||||||
@@ -5066,7 +4870,7 @@ export class Session {
|
|||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
await this.notifyGitMutation(cwd, "stash-push");
|
await this.notifyGitMutation(cwd, "stash-push");
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutSession.scheduleDiffRefresh(cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "stash_save_response",
|
type: "stash_save_response",
|
||||||
payload: { cwd, success: true, error: null, requestId },
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
@@ -5088,7 +4892,7 @@ export class Session {
|
|||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
await this.notifyGitMutation(cwd, "stash-pop");
|
await this.notifyGitMutation(cwd, "stash-pop");
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutSession.scheduleDiffRefresh(cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "stash_pop_response",
|
type: "stash_pop_response",
|
||||||
payload: { cwd, success: true, error: null, requestId },
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
@@ -5140,7 +4944,7 @@ export class Session {
|
|||||||
addAll: msg.addAll ?? true,
|
addAll: msg.addAll ?? true,
|
||||||
});
|
});
|
||||||
await this.notifyGitMutation(cwd, "commit-changes");
|
await this.notifyGitMutation(cwd, "commit-changes");
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutSession.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_commit_response",
|
type: "checkout_commit_response",
|
||||||
@@ -5201,7 +5005,7 @@ export class Session {
|
|||||||
this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
|
this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
|
||||||
...(mutatedCwd !== cwd ? [this.notifyGitMutation(cwd, "merge-to-base")] : []),
|
...(mutatedCwd !== cwd ? [this.notifyGitMutation(cwd, "merge-to-base")] : []),
|
||||||
]);
|
]);
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutSession.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_merge_response",
|
type: "checkout_merge_response",
|
||||||
@@ -5243,7 +5047,7 @@ export class Session {
|
|||||||
requireCleanTarget: msg.requireCleanTarget ?? true,
|
requireCleanTarget: msg.requireCleanTarget ?? true,
|
||||||
});
|
});
|
||||||
await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
|
await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutSession.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_merge_from_base_response",
|
type: "checkout_merge_from_base_response",
|
||||||
@@ -5275,7 +5079,7 @@ export class Session {
|
|||||||
try {
|
try {
|
||||||
await pullCurrentBranch(cwd);
|
await pullCurrentBranch(cwd);
|
||||||
await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
|
await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutSession.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_pull_response",
|
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(
|
private async handleCheckoutPrCreateRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -8683,10 +8452,7 @@ export class Session {
|
|||||||
|
|
||||||
this.terminalController.dispose();
|
this.terminalController.dispose();
|
||||||
|
|
||||||
for (const unsubscribe of this.checkoutDiffSubscriptions.values()) {
|
this.checkoutSession.cleanup();
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
this.checkoutDiffSubscriptions.clear();
|
|
||||||
|
|
||||||
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
|
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
|
||||||
unsubscribe();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user