refactor(server): extract git-mutation primitives into git-mutation module (#1711)
* refactor(server): extract git-mutation primitives into git-mutation module Move checkoutExistingBranch, createBranchFromBase and notifyGitMutation (plus their internal ref-validation / clean-tree / branch-existence helpers) out of the 6.5k-line Session class into session/git-mutation/git-mutation-service.ts — a deep module with a 3-method interface and a createGitMutationService(deps) factory, mirroring the git-metadata-generator port extracted in #1702. These primitives were smeared across three sub-session boundaries as host callbacks. CheckoutSession now takes gitMutation directly, shrinking CheckoutSessionHost from 6 members to 4; the worktree session-config builder and the auto-naming / worktree-creation paths call this.gitMutation.* instead of private Session methods. Adds git-mutation-service.test.ts: guard branches covered with in-memory fakes, happy paths against a real temp git repo. The superseded internal-mock tests in session.test.ts (which stubbed execCommand and the git service) are removed in favour of the real-dependency coverage. * refactor(server): address review on git-mutation-service - Inline doesLocalBranchExist into its sole caller: once the redundant ref-validation is removed (createBranchFromBase already validates newBranchName with the "new branch" label), the helper is a pure passthrough to workspaceGitService.hasLocalBranch. - Replace .some(...).toBe(true) snapshot assertions with toContainEqual so failures surface the full recorded call (and assert cwd too).
This commit is contained in:
@@ -65,7 +65,6 @@ interface SessionHandlerInternals {
|
|||||||
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
||||||
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
||||||
handleValidateBranchRequest(params: unknown): Promise<unknown>;
|
handleValidateBranchRequest(params: unknown): Promise<unknown>;
|
||||||
createBranchFromBase(params: unknown): Promise<unknown>;
|
|
||||||
handleCheckoutSwitchBranchRequest(params: unknown): Promise<unknown>;
|
handleCheckoutSwitchBranchRequest(params: unknown): Promise<unknown>;
|
||||||
handleBranchSuggestionsRequest(params: unknown): Promise<unknown>;
|
handleBranchSuggestionsRequest(params: unknown): Promise<unknown>;
|
||||||
handleStashListRequest(params: unknown): Promise<unknown>;
|
handleStashListRequest(params: unknown): Promise<unknown>;
|
||||||
@@ -3253,101 +3252,6 @@ describe("session branch validation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("session branch creation handling", () => {
|
|
||||||
test("validates the base branch through the workspace git service", async () => {
|
|
||||||
const workspaceGitService = {
|
|
||||||
getSnapshot: vi.fn(),
|
|
||||||
validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }),
|
|
||||||
hasLocalBranch: vi.fn(),
|
|
||||||
};
|
|
||||||
const session = createSessionForTest({ workspaceGitService });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
asSessionInternals(session).createBranchFromBase({
|
|
||||||
cwd: "/tmp/repo",
|
|
||||||
baseBranch: "missing-base",
|
|
||||||
newBranchName: "feature/new-work",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Base branch not found: missing-base");
|
|
||||||
|
|
||||||
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledTimes(1);
|
|
||||||
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "missing-base");
|
|
||||||
expect(workspaceGitService.hasLocalBranch).not.toHaveBeenCalled();
|
|
||||||
expect(spawnMocks.execCommand).not.toHaveBeenCalledWith(
|
|
||||||
"git",
|
|
||||||
["rev-parse", "--verify", "missing-base"],
|
|
||||||
{ cwd: "/tmp/repo" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checks local branch existence through the workspace git service", async () => {
|
|
||||||
const workspaceGitService = {
|
|
||||||
getSnapshot: vi.fn(),
|
|
||||||
validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "main" }),
|
|
||||||
hasLocalBranch: vi.fn().mockResolvedValue(true),
|
|
||||||
};
|
|
||||||
const session = createSessionForTest({ workspaceGitService });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
asSessionInternals(session).createBranchFromBase({
|
|
||||||
cwd: "/tmp/repo",
|
|
||||||
baseBranch: "main",
|
|
||||||
newBranchName: "feature/existing",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Branch already exists: feature/existing");
|
|
||||||
|
|
||||||
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "main");
|
|
||||||
expect(workspaceGitService.hasLocalBranch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(workspaceGitService.hasLocalBranch).toHaveBeenCalledWith(
|
|
||||||
"/tmp/repo",
|
|
||||||
"feature/existing",
|
|
||||||
);
|
|
||||||
expect(spawnMocks.execCommand).not.toHaveBeenCalledWith(
|
|
||||||
"git",
|
|
||||||
["show-ref", "--verify", "--quiet", "refs/heads/feature/existing"],
|
|
||||||
{ cwd: "/tmp/repo" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forces a workspace git snapshot refresh after creating a branch", async () => {
|
|
||||||
const workspaceGitService = {
|
|
||||||
getSnapshot: vi.fn().mockResolvedValue(
|
|
||||||
createWorkspaceGitSnapshot("/tmp/repo", {
|
|
||||||
git: {
|
|
||||||
isDirty: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "main" }),
|
|
||||||
hasLocalBranch: vi.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const session = createSessionForTest({ workspaceGitService });
|
|
||||||
spawnMocks.execCommand.mockResolvedValue({
|
|
||||||
stdout: "",
|
|
||||||
stderr: "",
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await asSessionInternals(session).createBranchFromBase({
|
|
||||||
cwd: "/tmp/repo",
|
|
||||||
baseBranch: "main",
|
|
||||||
newBranchName: "feature/new-work",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(spawnMocks.execCommand).toHaveBeenCalledWith(
|
|
||||||
"git",
|
|
||||||
["checkout", "-b", "feature/new-work", "main"],
|
|
||||||
{ cwd: "/tmp/repo" },
|
|
||||||
);
|
|
||||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
|
||||||
force: true,
|
|
||||||
reason: "create-branch",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("session checkout switch branch handling", () => {
|
describe("session checkout switch branch handling", () => {
|
||||||
test("forces a workspace git snapshot refresh after switching branches", async () => {
|
test("forces a workspace git snapshot refresh after switching branches", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
|
|||||||
@@ -159,12 +159,11 @@ import {
|
|||||||
} from "./workspace-archive-service.js";
|
} from "./workspace-archive-service.js";
|
||||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||||
|
import { renameCurrentBranch as renameCurrentBranchDefault } from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
checkoutResolvedBranch,
|
createGitMutationService,
|
||||||
type CheckoutExistingBranchResult,
|
type GitMutationService,
|
||||||
type GitMutationRefreshReason,
|
} from "./session/git-mutation/git-mutation-service.js";
|
||||||
renameCurrentBranch as renameCurrentBranchDefault,
|
|
||||||
} from "../utils/checkout-git.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 type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
@@ -174,7 +173,6 @@ import type pino from "pino";
|
|||||||
import { FileBackedChatService } from "./chat/chat-service.js";
|
import { FileBackedChatService } from "./chat/chat-service.js";
|
||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
import { ScheduleService } from "./schedule/service.js";
|
import { ScheduleService } from "./schedule/service.js";
|
||||||
import { execCommand } from "../utils/spawn.js";
|
|
||||||
import { createGitHubService, type GitHubService } from "../services/github-service.js";
|
import { createGitHubService, type GitHubService } from "../services/github-service.js";
|
||||||
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||||
import {
|
import {
|
||||||
@@ -196,7 +194,6 @@ import {
|
|||||||
type GeneratedWorkspaceName,
|
type GeneratedWorkspaceName,
|
||||||
} from "./worktree-branch-name-generator.js";
|
} from "./worktree-branch-name-generator.js";
|
||||||
import {
|
import {
|
||||||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
|
||||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||||
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
|
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
|
||||||
type CreatePaseoWorktreeSetupContinuationInput,
|
type CreatePaseoWorktreeSetupContinuationInput,
|
||||||
@@ -566,6 +563,7 @@ export class Session {
|
|||||||
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
||||||
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
|
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
|
||||||
private readonly workspaceGitService: WorkspaceGitService;
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
|
private readonly gitMutation: GitMutationService;
|
||||||
private readonly daemonConfigStore: DaemonConfigStore;
|
private readonly daemonConfigStore: DaemonConfigStore;
|
||||||
private readonly mcpBaseUrl: string | null;
|
private readonly mcpBaseUrl: string | null;
|
||||||
private readonly pushTokenStore: PushTokenStore;
|
private readonly pushTokenStore: PushTokenStore;
|
||||||
@@ -697,17 +695,20 @@ export class Session {
|
|||||||
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
||||||
this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
|
this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
|
||||||
this.workspaceGitService = workspaceGitService;
|
this.workspaceGitService = workspaceGitService;
|
||||||
|
this.gitMutation = createGitMutationService({
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
|
github: this.github,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
this.checkoutSession = new CheckoutSession({
|
this.checkoutSession = new CheckoutSession({
|
||||||
host: {
|
host: {
|
||||||
emit: (msg) => this.emit(msg),
|
emit: (msg) => this.emit(msg),
|
||||||
notifyGitMutation: (cwd, reason, mutationOptions) =>
|
|
||||||
this.notifyGitMutation(cwd, reason, mutationOptions),
|
|
||||||
emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd),
|
emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd),
|
||||||
handleWorkspaceGitBranchSnapshot: (cwd, branchName) =>
|
handleWorkspaceGitBranchSnapshot: (cwd, branchName) =>
|
||||||
this.handleWorkspaceGitBranchSnapshot(cwd, branchName),
|
this.handleWorkspaceGitBranchSnapshot(cwd, branchName),
|
||||||
renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch),
|
renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch),
|
||||||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
|
||||||
},
|
},
|
||||||
|
gitMutation: this.gitMutation,
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
github: this.github,
|
github: this.github,
|
||||||
checkoutDiffManager,
|
checkoutDiffManager,
|
||||||
@@ -3074,8 +3075,9 @@ export class Session {
|
|||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
checkoutExistingBranch: (cwd, branch) =>
|
||||||
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
this.gitMutation.checkoutExistingBranch(cwd, branch),
|
||||||
|
createBranchFromBase: (params) => this.gitMutation.createBranchFromBase(params),
|
||||||
github: this.github,
|
github: this.github,
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
@@ -3134,7 +3136,7 @@ export class Session {
|
|||||||
branch: result.branchName,
|
branch: result.branchName,
|
||||||
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||||
});
|
});
|
||||||
await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
|
await this.gitMutation.notifyGitMutation(input.workspace.cwd, "rename-branch");
|
||||||
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
|
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3237,13 +3239,6 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertSafeGitRef(ref: string, label: string): void {
|
|
||||||
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
|
||||||
throw new Error(`Invalid ${label}: ${ref}`);
|
|
||||||
}
|
|
||||||
assertWorktreeSafeGitRef(ref, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
|
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
|
||||||
const resolvedRoot = resolve(rootPath);
|
const resolvedRoot = resolve(rootPath);
|
||||||
const resolvedCandidate = resolve(candidatePath);
|
const resolvedCandidate = resolve(candidatePath);
|
||||||
@@ -3253,93 +3248,6 @@ export class Session {
|
|||||||
return resolvedCandidate.startsWith(resolvedRoot + sep);
|
return resolvedCandidate.startsWith(resolvedRoot + sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
|
|
||||||
const dirty = await this.isWorkingTreeDirty(cwd);
|
|
||||||
if (dirty) {
|
|
||||||
throw new Error(
|
|
||||||
"Working directory has uncommitted changes. Commit or stash before switching branches.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
|
||||||
return snapshot.git.isDirty === true;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Unable to inspect git status for ${cwd}: ${getErrorMessage(error)}`, {
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkoutExistingBranch(
|
|
||||||
cwd: string,
|
|
||||||
branch: string,
|
|
||||||
): Promise<CheckoutExistingBranchResult> {
|
|
||||||
this.assertSafeGitRef(branch, "branch");
|
|
||||||
const resolution = await this.workspaceGitService.validateBranchRef(cwd, branch);
|
|
||||||
if (resolution.kind === "not-found") {
|
|
||||||
throw new Error(`Branch not found: ${branch}`);
|
|
||||||
}
|
|
||||||
await this.ensureCleanWorkingTree(cwd);
|
|
||||||
const result = await checkoutResolvedBranch({
|
|
||||||
cwd,
|
|
||||||
resolution,
|
|
||||||
});
|
|
||||||
await this.notifyGitMutation(cwd, "switch-branch", { invalidateGithub: true });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createBranchFromBase(params: {
|
|
||||||
cwd: string;
|
|
||||||
baseBranch: string;
|
|
||||||
newBranchName: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const { cwd, baseBranch, newBranchName } = params;
|
|
||||||
this.assertSafeGitRef(baseBranch, "base branch");
|
|
||||||
this.assertSafeGitRef(newBranchName, "new branch");
|
|
||||||
|
|
||||||
const baseResolution = await this.workspaceGitService.validateBranchRef(cwd, baseBranch);
|
|
||||||
if (baseResolution.kind === "not-found") {
|
|
||||||
throw new Error(`Base branch not found: ${baseBranch}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = await this.doesLocalBranchExist(cwd, newBranchName);
|
|
||||||
if (exists) {
|
|
||||||
throw new Error(`Branch already exists: ${newBranchName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.ensureCleanWorkingTree(cwd);
|
|
||||||
await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], {
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
await this.notifyGitMutation(cwd, "create-branch");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
|
||||||
this.assertSafeGitRef(branch, "branch");
|
|
||||||
return this.workspaceGitService.hasLocalBranch(cwd, branch);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async notifyGitMutation(
|
|
||||||
cwd: string,
|
|
||||||
reason: GitMutationRefreshReason,
|
|
||||||
options?: { invalidateGithub?: boolean },
|
|
||||||
): Promise<void> {
|
|
||||||
if (options?.invalidateGithub) {
|
|
||||||
this.github.invalidate({ cwd });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.workspaceGitService.getSnapshot(cwd, { force: true, reason });
|
|
||||||
} catch (error) {
|
|
||||||
this.sessionLogger.warn(
|
|
||||||
{ err: error, cwd, reason },
|
|
||||||
"Failed to force-refresh workspace git snapshot after mutation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle clearing agent attention flag
|
* Handle clearing agent attention flag
|
||||||
*/
|
*/
|
||||||
@@ -4715,8 +4623,8 @@ export class Session {
|
|||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
});
|
});
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
this.notifyGitMutation(input.cwd, "create-worktree"),
|
this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"),
|
||||||
this.notifyGitMutation(result.worktree.worktreePath, "create-worktree"),
|
this.gitMutation.notifyGitMutation(result.worktree.worktreePath, "create-worktree"),
|
||||||
]).catch((error) => {
|
]).catch((error) => {
|
||||||
this.sessionLogger.warn(
|
this.sessionLogger.warn(
|
||||||
{ err: error, cwd: input.cwd, worktreePath: result.worktree.worktreePath },
|
{ err: error, cwd: input.cwd, worktreePath: result.worktree.worktreePath },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CheckoutSession,
|
CheckoutSession,
|
||||||
type CheckoutSessionHost,
|
type CheckoutSessionHost,
|
||||||
} from "./checkout-session.js";
|
} from "./checkout-session.js";
|
||||||
|
import type { GitMutationService } from "../git-mutation/git-mutation-service.js";
|
||||||
import { createGitHubService, type GitHubService } from "../../../services/github-service.js";
|
import { createGitHubService, type GitHubService } from "../../../services/github-service.js";
|
||||||
import type { SessionOutboundMessage } from "../../messages.js";
|
import type { SessionOutboundMessage } from "../../messages.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -56,14 +57,19 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RecordedHostCalls {
|
interface RecordedHostCalls {
|
||||||
|
emitWorkspaceUpdateForCwd: string[];
|
||||||
|
handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>;
|
||||||
|
renameCurrentBranch: Array<{ cwd: string; branch: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitMutationFake = Pick<GitMutationService, "checkoutExistingBranch" | "notifyGitMutation">;
|
||||||
|
|
||||||
|
interface RecordedGitMutationCalls {
|
||||||
notifyGitMutation: Array<{
|
notifyGitMutation: Array<{
|
||||||
cwd: string;
|
cwd: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
options?: { invalidateGithub?: boolean };
|
options?: { invalidateGithub?: boolean };
|
||||||
}>;
|
}>;
|
||||||
emitWorkspaceUpdateForCwd: string[];
|
|
||||||
handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>;
|
|
||||||
renameCurrentBranch: Array<{ cwd: string; branch: string }>;
|
|
||||||
checkoutExistingBranch: Array<{ cwd: string; branch: string }>;
|
checkoutExistingBranch: Array<{ cwd: string; branch: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +83,17 @@ function makeCheckoutSession(options?: {
|
|||||||
diff?: CheckoutDiffSubscriber;
|
diff?: CheckoutDiffSubscriber;
|
||||||
github?: Partial<GitHubService>;
|
github?: Partial<GitHubService>;
|
||||||
host?: Partial<CheckoutSessionHost>;
|
host?: Partial<CheckoutSessionHost>;
|
||||||
|
gitMutation?: Partial<GitMutationFake>;
|
||||||
gitMetadataGenerator?: Partial<GitMetadataGenerator>;
|
gitMetadataGenerator?: Partial<GitMetadataGenerator>;
|
||||||
}) {
|
}) {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const hostCalls: RecordedHostCalls = {
|
const hostCalls: RecordedHostCalls = {
|
||||||
notifyGitMutation: [],
|
|
||||||
emitWorkspaceUpdateForCwd: [],
|
emitWorkspaceUpdateForCwd: [],
|
||||||
handleWorkspaceGitBranchSnapshot: [],
|
handleWorkspaceGitBranchSnapshot: [],
|
||||||
renameCurrentBranch: [],
|
renameCurrentBranch: [],
|
||||||
|
};
|
||||||
|
const gitMutationCalls: RecordedGitMutationCalls = {
|
||||||
|
notifyGitMutation: [],
|
||||||
checkoutExistingBranch: [],
|
checkoutExistingBranch: [],
|
||||||
};
|
};
|
||||||
const generatorCalls: RecordedGeneratorCalls = {
|
const generatorCalls: RecordedGeneratorCalls = {
|
||||||
@@ -93,9 +102,6 @@ function makeCheckoutSession(options?: {
|
|||||||
};
|
};
|
||||||
const host: CheckoutSessionHost = {
|
const host: CheckoutSessionHost = {
|
||||||
emit: (msg) => emitted.push(msg),
|
emit: (msg) => emitted.push(msg),
|
||||||
notifyGitMutation: async (cwd, reason, opts) => {
|
|
||||||
hostCalls.notifyGitMutation.push({ cwd, reason, options: opts });
|
|
||||||
},
|
|
||||||
emitWorkspaceUpdateForCwd: async (cwd) => {
|
emitWorkspaceUpdateForCwd: async (cwd) => {
|
||||||
hostCalls.emitWorkspaceUpdateForCwd.push(cwd);
|
hostCalls.emitWorkspaceUpdateForCwd.push(cwd);
|
||||||
},
|
},
|
||||||
@@ -106,11 +112,17 @@ function makeCheckoutSession(options?: {
|
|||||||
hostCalls.renameCurrentBranch.push({ cwd, branch });
|
hostCalls.renameCurrentBranch.push({ cwd, branch });
|
||||||
return { previousBranch: null, currentBranch: branch };
|
return { previousBranch: null, currentBranch: branch };
|
||||||
},
|
},
|
||||||
|
...options?.host,
|
||||||
|
};
|
||||||
|
const gitMutation: GitMutationFake = {
|
||||||
|
notifyGitMutation: async (cwd, reason, opts) => {
|
||||||
|
gitMutationCalls.notifyGitMutation.push({ cwd, reason, options: opts });
|
||||||
|
},
|
||||||
checkoutExistingBranch: async (cwd, branch) => {
|
checkoutExistingBranch: async (cwd, branch) => {
|
||||||
hostCalls.checkoutExistingBranch.push({ cwd, branch });
|
gitMutationCalls.checkoutExistingBranch.push({ cwd, branch });
|
||||||
return { source: "local" };
|
return { source: "local" };
|
||||||
},
|
},
|
||||||
...options?.host,
|
...options?.gitMutation,
|
||||||
};
|
};
|
||||||
const gitMetadataGenerator: GitMetadataGenerator = {
|
const gitMetadataGenerator: GitMetadataGenerator = {
|
||||||
generateCommitMessage: async (cwd) => {
|
generateCommitMessage: async (cwd) => {
|
||||||
@@ -126,6 +138,7 @@ function makeCheckoutSession(options?: {
|
|||||||
const github: GitHubService = { ...createGitHubService(), ...options?.github };
|
const github: GitHubService = { ...createGitHubService(), ...options?.github };
|
||||||
const checkout = new CheckoutSession({
|
const checkout = new CheckoutSession({
|
||||||
host,
|
host,
|
||||||
|
gitMutation,
|
||||||
workspaceGitService: createNoopWorkspaceGitService(options?.git),
|
workspaceGitService: createNoopWorkspaceGitService(options?.git),
|
||||||
github,
|
github,
|
||||||
checkoutDiffManager:
|
checkoutDiffManager:
|
||||||
@@ -135,7 +148,7 @@ function makeCheckoutSession(options?: {
|
|||||||
worktreesRoot: undefined,
|
worktreesRoot: undefined,
|
||||||
logger: pino({ level: "silent" }),
|
logger: pino({ level: "silent" }),
|
||||||
});
|
});
|
||||||
return { checkout, emitted, hostCalls, generatorCalls };
|
return { checkout, emitted, hostCalls, gitMutationCalls, generatorCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGitSnapshot(
|
function createGitSnapshot(
|
||||||
@@ -534,7 +547,9 @@ describe("CheckoutSession", () => {
|
|||||||
files: [],
|
files: [],
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
const { checkout, emitted, hostCalls } = makeCheckoutSession({ diff: subscriber });
|
const { checkout, emitted, hostCalls, gitMutationCalls } = makeCheckoutSession({
|
||||||
|
diff: subscriber,
|
||||||
|
});
|
||||||
|
|
||||||
await checkout.handleCheckoutSwitchBranchRequest({
|
await checkout.handleCheckoutSwitchBranchRequest({
|
||||||
type: "checkout_switch_branch_request",
|
type: "checkout_switch_branch_request",
|
||||||
@@ -543,7 +558,9 @@ describe("CheckoutSession", () => {
|
|||||||
requestId: "sw1",
|
requestId: "sw1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(hostCalls.checkoutExistingBranch).toEqual([{ cwd: "/repo", branch: "feature" }]);
|
expect(gitMutationCalls.checkoutExistingBranch).toEqual([
|
||||||
|
{ cwd: "/repo", branch: "feature" },
|
||||||
|
]);
|
||||||
expect(refreshedCwds).toEqual(["/repo"]);
|
expect(refreshedCwds).toEqual(["/repo"]);
|
||||||
expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]);
|
expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]);
|
||||||
expect(emitted).toEqual([
|
expect(emitted).toEqual([
|
||||||
@@ -563,7 +580,7 @@ describe("CheckoutSession", () => {
|
|||||||
|
|
||||||
it("emits an error response when the checkout fails", async () => {
|
it("emits an error response when the checkout fails", async () => {
|
||||||
const { checkout, emitted } = makeCheckoutSession({
|
const { checkout, emitted } = makeCheckoutSession({
|
||||||
host: {
|
gitMutation: {
|
||||||
checkoutExistingBranch: async () => {
|
checkoutExistingBranch: async () => {
|
||||||
throw new Error("branch missing");
|
throw new Error("branch missing");
|
||||||
},
|
},
|
||||||
@@ -617,7 +634,9 @@ describe("CheckoutSession", () => {
|
|||||||
files: [],
|
files: [],
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
const { checkout, emitted, hostCalls } = makeCheckoutSession({ diff: subscriber });
|
const { checkout, emitted, hostCalls, gitMutationCalls } = makeCheckoutSession({
|
||||||
|
diff: subscriber,
|
||||||
|
});
|
||||||
|
|
||||||
await checkout.handleCheckoutRenameBranchRequest({
|
await checkout.handleCheckoutRenameBranchRequest({
|
||||||
type: "checkout.rename_branch.request",
|
type: "checkout.rename_branch.request",
|
||||||
@@ -627,7 +646,7 @@ describe("CheckoutSession", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(hostCalls.renameCurrentBranch).toEqual([{ cwd: "/repo", branch: "feature-renamed" }]);
|
expect(hostCalls.renameCurrentBranch).toEqual([{ cwd: "/repo", branch: "feature-renamed" }]);
|
||||||
expect(hostCalls.notifyGitMutation).toEqual([
|
expect(gitMutationCalls.notifyGitMutation).toEqual([
|
||||||
{ cwd: "/repo", reason: "rename-branch", options: { invalidateGithub: true } },
|
{ cwd: "/repo", reason: "rename-branch", options: { invalidateGithub: true } },
|
||||||
]);
|
]);
|
||||||
expect(refreshedCwds).toEqual(["/repo"]);
|
expect(refreshedCwds).toEqual(["/repo"]);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
WorkspaceGitSnapshotOptions,
|
WorkspaceGitSnapshotOptions,
|
||||||
} from "../../workspace-git-service.js";
|
} from "../../workspace-git-service.js";
|
||||||
import { assertSafeGitRef } from "../../worktree-session.js";
|
import { assertSafeGitRef } from "../../worktree-session.js";
|
||||||
|
import type { GitMutationService } from "../git-mutation/git-mutation-service.js";
|
||||||
import {
|
import {
|
||||||
assertPullRequestAutoMergeDisableReady,
|
assertPullRequestAutoMergeDisableReady,
|
||||||
assertPullRequestAutoMergeEnableReady,
|
assertPullRequestAutoMergeEnableReady,
|
||||||
@@ -34,10 +35,8 @@ import {
|
|||||||
type PullRequestTimelineItem,
|
type PullRequestTimelineItem,
|
||||||
} from "../../../services/github-service.js";
|
} from "../../../services/github-service.js";
|
||||||
import {
|
import {
|
||||||
type CheckoutExistingBranchResult,
|
|
||||||
commitChanges,
|
commitChanges,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
type GitMutationRefreshReason,
|
|
||||||
mergeFromBase,
|
mergeFromBase,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
pullCurrentBranch,
|
pullCurrentBranch,
|
||||||
@@ -49,24 +48,21 @@ import type { GitMetadataGenerator } from "./git-metadata-generator.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The collaborators a checkout command reaches that are NOT part of the checkout
|
* The collaborators a checkout command reaches that are NOT part of the checkout
|
||||||
* domain: the git-mutation refresh primitive and workspace-update emitters owned
|
* domain and stay owned by the Session shell: client emit, workspace-update
|
||||||
* by the Session shell (also used by worktree/workspace creation), and the injected
|
* emission, the git branch-snapshot notifier, and the current-branch rename
|
||||||
* branch operations. CheckoutSession orchestrates them but does not own them.
|
* primitive. CheckoutSession orchestrates them but does not own them. The
|
||||||
|
* git-mutation primitives it performs (switch branch, force snapshot refresh) are
|
||||||
|
* injected separately as `gitMutation`, since they are shared with worktree and
|
||||||
|
* workspace creation.
|
||||||
*/
|
*/
|
||||||
export interface CheckoutSessionHost {
|
export interface CheckoutSessionHost {
|
||||||
emit(msg: SessionOutboundMessage): void;
|
emit(msg: SessionOutboundMessage): void;
|
||||||
notifyGitMutation(
|
|
||||||
cwd: string,
|
|
||||||
reason: GitMutationRefreshReason,
|
|
||||||
options?: { invalidateGithub?: boolean },
|
|
||||||
): Promise<void>;
|
|
||||||
emitWorkspaceUpdateForCwd(cwd: string): Promise<void>;
|
emitWorkspaceUpdateForCwd(cwd: string): Promise<void>;
|
||||||
handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void;
|
handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void;
|
||||||
renameCurrentBranch(
|
renameCurrentBranch(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
branch: string,
|
branch: string,
|
||||||
): Promise<{ previousBranch: string | null; currentBranch: string | null }>;
|
): Promise<{ previousBranch: string | null; currentBranch: string | null }>;
|
||||||
checkoutExistingBranch(cwd: string, branch: string): Promise<CheckoutExistingBranchResult>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CurrentWorkspacePullRequest = NonNullable<
|
type CurrentWorkspacePullRequest = NonNullable<
|
||||||
@@ -90,6 +86,7 @@ export interface CheckoutDiffSubscriber {
|
|||||||
|
|
||||||
export interface CheckoutSessionOptions {
|
export interface CheckoutSessionOptions {
|
||||||
host: CheckoutSessionHost;
|
host: CheckoutSessionHost;
|
||||||
|
gitMutation: Pick<GitMutationService, "checkoutExistingBranch" | "notifyGitMutation">;
|
||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
checkoutDiffManager: CheckoutDiffSubscriber;
|
checkoutDiffManager: CheckoutDiffSubscriber;
|
||||||
@@ -106,13 +103,17 @@ export interface CheckoutSessionOptions {
|
|||||||
* merge/pull/push/stash and the GitHub-PR operations).
|
* merge/pull/push/stash and the GitHub-PR operations).
|
||||||
*
|
*
|
||||||
* Command operations keep the live diff in sync by calling scheduleDiffRefresh()
|
* Command operations keep the live diff in sync by calling scheduleDiffRefresh()
|
||||||
* and refresh the workspace git snapshot through host.notifyGitMutation(); the
|
* and refresh the workspace git snapshot through gitMutation.notifyGitMutation(); the
|
||||||
* workspace git observer streams branch changes through emitStatusUpdate().
|
* workspace git observer streams branch changes through emitStatusUpdate().
|
||||||
*/
|
*/
|
||||||
export class CheckoutSession {
|
export class CheckoutSession {
|
||||||
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
|
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
|
||||||
|
|
||||||
private readonly host: CheckoutSessionHost;
|
private readonly host: CheckoutSessionHost;
|
||||||
|
private readonly gitMutation: Pick<
|
||||||
|
GitMutationService,
|
||||||
|
"checkoutExistingBranch" | "notifyGitMutation"
|
||||||
|
>;
|
||||||
private readonly workspaceGitService: WorkspaceGitService;
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
private readonly github: GitHubService;
|
private readonly github: GitHubService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
|
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
|
||||||
@@ -124,6 +125,7 @@ export class CheckoutSession {
|
|||||||
|
|
||||||
constructor(options: CheckoutSessionOptions) {
|
constructor(options: CheckoutSessionOptions) {
|
||||||
this.host = options.host;
|
this.host = options.host;
|
||||||
|
this.gitMutation = options.gitMutation;
|
||||||
this.workspaceGitService = options.workspaceGitService;
|
this.workspaceGitService = options.workspaceGitService;
|
||||||
this.github = options.github;
|
this.github = options.github;
|
||||||
this.checkoutDiffManager = options.checkoutDiffManager;
|
this.checkoutDiffManager = options.checkoutDiffManager;
|
||||||
@@ -373,7 +375,7 @@ export class CheckoutSession {
|
|||||||
const { cwd, branch, requestId } = msg;
|
const { cwd, branch, requestId } = msg;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const checkoutResult = await this.host.checkoutExistingBranch(cwd, branch);
|
const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch);
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
// Push a workspace_update immediately so the sidebar/header reflect
|
// Push a workspace_update immediately so the sidebar/header reflect
|
||||||
@@ -425,7 +427,7 @@ export class CheckoutSession {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.host.renameCurrentBranch(cwd, branch);
|
const result = await this.host.renameCurrentBranch(cwd, branch);
|
||||||
await this.host.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
|
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
||||||
|
|
||||||
@@ -474,7 +476,7 @@ export class CheckoutSession {
|
|||||||
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], {
|
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], {
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
await this.host.notifyGitMutation(cwd, "stash-push");
|
await this.gitMutation.notifyGitMutation(cwd, "stash-push");
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "stash_save_response",
|
type: "stash_save_response",
|
||||||
@@ -496,7 +498,7 @@ export class CheckoutSession {
|
|||||||
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], {
|
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], {
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
await this.host.notifyGitMutation(cwd, "stash-pop");
|
await this.gitMutation.notifyGitMutation(cwd, "stash-pop");
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "stash_pop_response",
|
type: "stash_pop_response",
|
||||||
@@ -548,7 +550,7 @@ export class CheckoutSession {
|
|||||||
message,
|
message,
|
||||||
addAll: msg.addAll ?? true,
|
addAll: msg.addAll ?? true,
|
||||||
});
|
});
|
||||||
await this.host.notifyGitMutation(cwd, "commit-changes");
|
await this.gitMutation.notifyGitMutation(cwd, "commit-changes");
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
@@ -607,8 +609,8 @@ export class CheckoutSession {
|
|||||||
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
|
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
|
||||||
);
|
);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.host.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
|
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
|
||||||
...(mutatedCwd !== cwd ? [this.host.notifyGitMutation(cwd, "merge-to-base")] : []),
|
...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []),
|
||||||
]);
|
]);
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
@@ -651,7 +653,7 @@ export class CheckoutSession {
|
|||||||
baseRef: msg.baseRef,
|
baseRef: msg.baseRef,
|
||||||
requireCleanTarget: msg.requireCleanTarget ?? true,
|
requireCleanTarget: msg.requireCleanTarget ?? true,
|
||||||
});
|
});
|
||||||
await this.host.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
|
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
@@ -683,7 +685,7 @@ export class CheckoutSession {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await pullCurrentBranch(cwd);
|
await pullCurrentBranch(cwd);
|
||||||
await this.host.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
|
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
|
||||||
this.scheduleDiffRefresh(cwd);
|
this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
@@ -715,7 +717,7 @@ export class CheckoutSession {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await pushCurrentBranch(cwd);
|
await pushCurrentBranch(cwd);
|
||||||
await this.host.notifyGitMutation(cwd, "push", { invalidateGithub: true });
|
await this.gitMutation.notifyGitMutation(cwd, "push", { invalidateGithub: true });
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout_push_response",
|
type: "checkout_push_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -762,7 +764,7 @@ export class CheckoutSession {
|
|||||||
},
|
},
|
||||||
this.github,
|
this.github,
|
||||||
);
|
);
|
||||||
await this.host.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
|
await this.gitMutation.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout_pr_create_response",
|
type: "checkout_pr_create_response",
|
||||||
@@ -806,7 +808,7 @@ export class CheckoutSession {
|
|||||||
mergeMethod: msg.mergeMethod,
|
mergeMethod: msg.mergeMethod,
|
||||||
status: pullRequest,
|
status: pullRequest,
|
||||||
});
|
});
|
||||||
await this.host.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
|
await this.gitMutation.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout_pr_merge_response",
|
type: "checkout_pr_merge_response",
|
||||||
@@ -875,7 +877,7 @@ export class CheckoutSession {
|
|||||||
status: pullRequest,
|
status: pullRequest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.host.notifyGitMutation(
|
await this.gitMutation.notifyGitMutation(
|
||||||
cwd,
|
cwd,
|
||||||
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
|
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pino } from "pino";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import type { GitHubService } from "../../../services/github-service.js";
|
||||||
|
import type {
|
||||||
|
WorkspaceGitBranchValidationResult,
|
||||||
|
WorkspaceGitRuntimeSnapshot,
|
||||||
|
WorkspaceGitService,
|
||||||
|
} from "../../workspace-git-service.js";
|
||||||
|
import { createGitMutationService } from "./git-mutation-service.js";
|
||||||
|
|
||||||
|
// The production module reads only WorkspaceGitService.{validateBranchRef,getSnapshot,hasLocalBranch}
|
||||||
|
// and GitHubService.invalidate. The fakes below implement exactly that slice as in-memory
|
||||||
|
// adapters; the happy-path tests cross the real git boundary against a temp repo, since that is
|
||||||
|
// where checkoutResolvedBranch / `git checkout -b` actually run.
|
||||||
|
|
||||||
|
type GitSource = Pick<WorkspaceGitService, "validateBranchRef" | "getSnapshot" | "hasLocalBranch">;
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
|
interface FakeGitOptions {
|
||||||
|
resolution?: WorkspaceGitBranchValidationResult;
|
||||||
|
isDirty?: boolean | null;
|
||||||
|
branchExists?: boolean;
|
||||||
|
getSnapshotThrows?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeGit(opts: FakeGitOptions = {}) {
|
||||||
|
const resolution = opts.resolution ?? { kind: "local", name: "main" };
|
||||||
|
const isDirty = opts.isDirty ?? false;
|
||||||
|
const branchExists = opts.branchExists ?? false;
|
||||||
|
const snapshotCalls: Array<{ cwd: string; force: boolean; reason?: string }> = [];
|
||||||
|
const git: GitSource = {
|
||||||
|
async validateBranchRef() {
|
||||||
|
return resolution;
|
||||||
|
},
|
||||||
|
async getSnapshot(cwd, options) {
|
||||||
|
snapshotCalls.push({ cwd, force: options?.force === true, reason: options?.reason });
|
||||||
|
if (opts.getSnapshotThrows) {
|
||||||
|
throw new Error("snapshot boom");
|
||||||
|
}
|
||||||
|
return { git: { isDirty } } as unknown as WorkspaceGitRuntimeSnapshot;
|
||||||
|
},
|
||||||
|
async hasLocalBranch() {
|
||||||
|
return branchExists;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { git, snapshotCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeGithub() {
|
||||||
|
const invalidateCalls: Array<{ cwd: string }> = [];
|
||||||
|
const github: Pick<GitHubService, "invalidate"> = {
|
||||||
|
invalidate(options) {
|
||||||
|
invalidateCalls.push(options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { github, invalidateCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildService(gitOptions: FakeGitOptions = {}) {
|
||||||
|
const { git, snapshotCalls } = createFakeGit(gitOptions);
|
||||||
|
const { github, invalidateCalls } = createFakeGithub();
|
||||||
|
const service = createGitMutationService({ workspaceGitService: git, github, logger });
|
||||||
|
return { service, snapshotCalls, invalidateCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRepos: string[] = [];
|
||||||
|
|
||||||
|
function initRepo(extraBranch?: string): string {
|
||||||
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "git-mutation-")));
|
||||||
|
tempRepos.push(dir);
|
||||||
|
const run = (...args: string[]) => execFileSync("git", args, { cwd: dir, stdio: "pipe" });
|
||||||
|
run("init", "-b", "main");
|
||||||
|
run("config", "user.email", "test@example.com");
|
||||||
|
run("config", "user.name", "Paseo Test");
|
||||||
|
writeFileSync(join(dir, "README.md"), "hello\n");
|
||||||
|
run("add", "-A");
|
||||||
|
run("commit", "-m", "init");
|
||||||
|
if (extraBranch) {
|
||||||
|
run("branch", extraBranch);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headBranch(dir: string): string {
|
||||||
|
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir }).toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (tempRepos.length > 0) {
|
||||||
|
const dir = tempRepos.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkoutExistingBranch", () => {
|
||||||
|
test("rejects an unsafe branch ref before touching git", async () => {
|
||||||
|
const { service } = buildService();
|
||||||
|
await expect(service.checkoutExistingBranch("/tmp/nope", "bad branch")).rejects.toThrow(
|
||||||
|
/Invalid branch/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects when the branch does not resolve", async () => {
|
||||||
|
const { service } = buildService({ resolution: { kind: "not-found" } });
|
||||||
|
await expect(service.checkoutExistingBranch("/tmp/nope", "missing")).rejects.toThrow(
|
||||||
|
/Branch not found: missing/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects when the working tree is dirty", async () => {
|
||||||
|
const { service } = buildService({ resolution: { kind: "local", name: "x" }, isDirty: true });
|
||||||
|
await expect(service.checkoutExistingBranch("/tmp/nope", "x")).rejects.toThrow(
|
||||||
|
/uncommitted changes/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wraps a git-status failure with the inspecting-status message", async () => {
|
||||||
|
const { service } = buildService({
|
||||||
|
resolution: { kind: "local", name: "x" },
|
||||||
|
getSnapshotThrows: true,
|
||||||
|
});
|
||||||
|
await expect(service.checkoutExistingBranch("/tmp/nope", "x")).rejects.toThrow(
|
||||||
|
/Unable to inspect git status/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checks out the branch and invalidates github (real repo)", async () => {
|
||||||
|
const dir = initRepo("feature");
|
||||||
|
const { service, snapshotCalls, invalidateCalls } = buildService({
|
||||||
|
resolution: { kind: "local", name: "feature" },
|
||||||
|
});
|
||||||
|
const result = await service.checkoutExistingBranch(dir, "feature");
|
||||||
|
expect(result.source).toBe("local");
|
||||||
|
expect(headBranch(dir)).toBe("feature");
|
||||||
|
expect(invalidateCalls).toEqual([{ cwd: dir }]);
|
||||||
|
expect(snapshotCalls).toContainEqual({ cwd: dir, force: true, reason: "switch-branch" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createBranchFromBase", () => {
|
||||||
|
test("rejects an unsafe new-branch ref before touching git", async () => {
|
||||||
|
const { service } = buildService({ resolution: { kind: "local", name: "main" } });
|
||||||
|
await expect(
|
||||||
|
service.createBranchFromBase({
|
||||||
|
cwd: "/tmp/nope",
|
||||||
|
baseBranch: "main",
|
||||||
|
newBranchName: "bad x",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Invalid new/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects when the base branch does not resolve", async () => {
|
||||||
|
const { service } = buildService({ resolution: { kind: "not-found" } });
|
||||||
|
await expect(
|
||||||
|
service.createBranchFromBase({ cwd: "/tmp/nope", baseBranch: "main", newBranchName: "feat" }),
|
||||||
|
).rejects.toThrow(/Base branch not found: main/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects when the new branch already exists", async () => {
|
||||||
|
const { service } = buildService({
|
||||||
|
resolution: { kind: "local", name: "main" },
|
||||||
|
branchExists: true,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.createBranchFromBase({ cwd: "/tmp/nope", baseBranch: "main", newBranchName: "feat" }),
|
||||||
|
).rejects.toThrow(/Branch already exists: feat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects when the working tree is dirty", async () => {
|
||||||
|
const { service } = buildService({
|
||||||
|
resolution: { kind: "local", name: "main" },
|
||||||
|
branchExists: false,
|
||||||
|
isDirty: true,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.createBranchFromBase({ cwd: "/tmp/nope", baseBranch: "main", newBranchName: "feat" }),
|
||||||
|
).rejects.toThrow(/uncommitted changes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates the branch from base and refreshes without github invalidation (real repo)", async () => {
|
||||||
|
const dir = initRepo();
|
||||||
|
const { service, snapshotCalls, invalidateCalls } = buildService({
|
||||||
|
resolution: { kind: "local", name: "main" },
|
||||||
|
});
|
||||||
|
await service.createBranchFromBase({ cwd: dir, baseBranch: "main", newBranchName: "feature2" });
|
||||||
|
expect(headBranch(dir)).toBe("feature2");
|
||||||
|
expect(invalidateCalls).toEqual([]);
|
||||||
|
expect(snapshotCalls).toContainEqual({ cwd: dir, force: true, reason: "create-branch" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notifyGitMutation", () => {
|
||||||
|
test("invalidates github and force-refreshes when invalidateGithub is set", async () => {
|
||||||
|
const { service, snapshotCalls, invalidateCalls } = buildService();
|
||||||
|
await service.notifyGitMutation("/tmp/repo", "commit-changes", { invalidateGithub: true });
|
||||||
|
expect(invalidateCalls).toEqual([{ cwd: "/tmp/repo" }]);
|
||||||
|
expect(snapshotCalls).toEqual([{ cwd: "/tmp/repo", force: true, reason: "commit-changes" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("force-refreshes without invalidating github by default", async () => {
|
||||||
|
const { service, snapshotCalls, invalidateCalls } = buildService();
|
||||||
|
await service.notifyGitMutation("/tmp/repo", "pull");
|
||||||
|
expect(invalidateCalls).toEqual([]);
|
||||||
|
expect(snapshotCalls).toEqual([{ cwd: "/tmp/repo", force: true, reason: "pull" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swallows a snapshot-refresh failure", async () => {
|
||||||
|
const { service } = buildService({ getSnapshotThrows: true });
|
||||||
|
await expect(service.notifyGitMutation("/tmp/repo", "pull")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type pino from "pino";
|
||||||
|
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||||
|
import type { GitHubService } from "../../../services/github-service.js";
|
||||||
|
import {
|
||||||
|
checkoutResolvedBranch,
|
||||||
|
type CheckoutExistingBranchResult,
|
||||||
|
type GitMutationRefreshReason,
|
||||||
|
} from "../../../utils/checkout-git.js";
|
||||||
|
import { execCommand } from "../../../utils/spawn.js";
|
||||||
|
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||||
|
import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-session.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The git branch / working-tree mutation primitives a client session performs on a
|
||||||
|
* workspace: switch to an existing branch, create a branch from a base, and force a
|
||||||
|
* snapshot refresh (plus optional GitHub cache invalidation) after any mutation.
|
||||||
|
*
|
||||||
|
* CheckoutSession (the branch/commit/merge commands), the worktree session-config
|
||||||
|
* builder, and the auto-naming + worktree-creation paths all funnel their git
|
||||||
|
* mutations through this one module, so the validate-ref → clean-tree → execute →
|
||||||
|
* refresh sequence lives in a single place instead of being smeared across the
|
||||||
|
* session as loose callbacks.
|
||||||
|
*/
|
||||||
|
export interface GitMutationService {
|
||||||
|
checkoutExistingBranch(cwd: string, branch: string): Promise<CheckoutExistingBranchResult>;
|
||||||
|
createBranchFromBase(params: {
|
||||||
|
cwd: string;
|
||||||
|
baseBranch: string;
|
||||||
|
newBranchName: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
notifyGitMutation(
|
||||||
|
cwd: string,
|
||||||
|
reason: GitMutationRefreshReason,
|
||||||
|
options?: { invalidateGithub?: boolean },
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitMutationGitSource = Pick<
|
||||||
|
WorkspaceGitService,
|
||||||
|
"validateBranchRef" | "getSnapshot" | "hasLocalBranch"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createGitMutationService(deps: {
|
||||||
|
workspaceGitService: GitMutationGitSource;
|
||||||
|
github: Pick<GitHubService, "invalidate">;
|
||||||
|
logger: pino.Logger;
|
||||||
|
}): GitMutationService {
|
||||||
|
const { workspaceGitService, github, logger } = deps;
|
||||||
|
|
||||||
|
function assertSafeGitRef(ref: string, label: string): void {
|
||||||
|
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
||||||
|
throw new Error(`Invalid ${label}: ${ref}`);
|
||||||
|
}
|
||||||
|
assertWorktreeSafeGitRef(ref, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const snapshot = await workspaceGitService.getSnapshot(cwd);
|
||||||
|
return snapshot.git.isDirty === true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Unable to inspect git status for ${cwd}: ${getErrorMessage(error)}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCleanWorkingTree(cwd: string): Promise<void> {
|
||||||
|
const dirty = await isWorkingTreeDirty(cwd);
|
||||||
|
if (dirty) {
|
||||||
|
throw new Error(
|
||||||
|
"Working directory has uncommitted changes. Commit or stash before switching branches.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyGitMutation(
|
||||||
|
cwd: string,
|
||||||
|
reason: GitMutationRefreshReason,
|
||||||
|
options?: { invalidateGithub?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
if (options?.invalidateGithub) {
|
||||||
|
github.invalidate({ cwd });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await workspaceGitService.getSnapshot(cwd, { force: true, reason });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, cwd, reason },
|
||||||
|
"Failed to force-refresh workspace git snapshot after mutation",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async checkoutExistingBranch(cwd, branch) {
|
||||||
|
assertSafeGitRef(branch, "branch");
|
||||||
|
const resolution = await workspaceGitService.validateBranchRef(cwd, branch);
|
||||||
|
if (resolution.kind === "not-found") {
|
||||||
|
throw new Error(`Branch not found: ${branch}`);
|
||||||
|
}
|
||||||
|
await ensureCleanWorkingTree(cwd);
|
||||||
|
const result = await checkoutResolvedBranch({ cwd, resolution });
|
||||||
|
await notifyGitMutation(cwd, "switch-branch", { invalidateGithub: true });
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBranchFromBase({ cwd, baseBranch, newBranchName }) {
|
||||||
|
assertSafeGitRef(baseBranch, "base branch");
|
||||||
|
assertSafeGitRef(newBranchName, "new branch");
|
||||||
|
|
||||||
|
const baseResolution = await workspaceGitService.validateBranchRef(cwd, baseBranch);
|
||||||
|
if (baseResolution.kind === "not-found") {
|
||||||
|
throw new Error(`Base branch not found: ${baseBranch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await workspaceGitService.hasLocalBranch(cwd, newBranchName);
|
||||||
|
if (exists) {
|
||||||
|
throw new Error(`Branch already exists: ${newBranchName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureCleanWorkingTree(cwd);
|
||||||
|
await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], { cwd });
|
||||||
|
await notifyGitMutation(cwd, "create-branch");
|
||||||
|
},
|
||||||
|
|
||||||
|
notifyGitMutation,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user