diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 51c86f3e3a165..f742213f5a866 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -19,6 +19,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -114,6 +115,7 @@ interface HarnessOptions { readonly removeWorktreeFails?: boolean; readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; + readonly existsFails?: boolean; } const makeHarness = (options: HarnessOptions = {}) => { @@ -233,7 +235,7 @@ const makeHarness = (options: HarnessOptions = {}) => { ), ), ); - const listRefs = vi.fn((input: { readonly query?: string | undefined }) => + const listRefs = vi.fn((input: { readonly cwd: string; readonly query?: string | undefined }) => Effect.succeed({ refs: options.existingBranchWorktreePath === undefined @@ -321,6 +323,19 @@ const makeHarness = (options: HarnessOptions = {}) => { } satisfies Partial), NodeServices.layer, NodeCrypto.layer, + options.existsFails + ? FileSystem.layerNoop({ + exists: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: path, + }), + ), + }) + : Layer.empty, ), ), ); @@ -328,6 +343,7 @@ const makeHarness = (options: HarnessOptions = {}) => { return { layer, scope, + listRefs, dispatch, sendToThread, fetchWorktreeBaseCommit, @@ -691,3 +707,34 @@ describe("t3_worktree_status", () => { }); }); }); + +describe("t3_worktree_list", () => { + const listRefs = (harness: ReturnType, worktreePath: string | null) => + Effect.gen(function* () { + const service = yield* WorktreeMcpService.WorktreeMcpService; + return yield* service.listRefs({ worktreePath, workspaceRoot }); + }).pipe(Effect.provide(harness.layer)); + + it.effect("lists refs from the thread's worktree, or the project root once it was removed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const worktreePath = yield* fs.makeTempDirectoryScoped({ prefix: "worktree-list-" }); + const harness = makeHarness(); + yield* listRefs(harness, worktreePath); + yield* listRefs(harness, `${worktreePath}/removed`); + expect(harness.listRefs.mock.calls.map(([input]) => input.cwd)).toEqual([ + worktreePath, + workspaceRoot, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("fails instead of falling back when the worktree cannot be checked", () => { + const harness = makeHarness({ existsFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(listRefs(harness, "/worktrees/project/unreadable")); + expectTypedFailure(exit, { _tag: "PlatformError" }); + expect(harness.listRefs).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 8a2231f6e4df4..35d79acaa3e20 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1,5 +1,6 @@ import { CommandId, + type GitCommandError, MessageId, type ProjectId, WorktreeMcpFailure, @@ -8,13 +9,17 @@ import { type WorktreeMcpHandoffResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, + type VcsListRefsInput, + type VcsListRefsResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as PlatformError from "effect/PlatformError"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -34,6 +39,12 @@ export class WorktreeMcpService extends Context.Service< readonly status: ( scope: McpInvocationScope, ) => Effect.Effect; + readonly listRefs: ( + input: Omit & { + readonly worktreePath: string | null; + readonly workspaceRoot: string; + }, + ) => Effect.Effect; } >()("t3/mcp/WorktreeMcpService") {} @@ -56,6 +67,7 @@ const asOperationFailed = (prefix: string) => const make = Effect.gen(function* () { const random = yield* SecureRandom.SecureRandom; + const fileSystem = yield* FileSystem.FileSystem; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const projects = yield* ProjectService.ProjectService; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -428,13 +440,25 @@ const make = Effect.gen(function* () { }, ); - return WorktreeMcpService.of({ handoff, status }); + const listRefs: WorktreeMcpService["Service"]["listRefs"] = Effect.fn( + "WorktreeMcpService.listRefs", + )(function* ({ worktreePath, workspaceRoot, ...refs }) { + // Storage cleanup removes a worktree but keeps its path on the thread. + const cwd = + worktreePath !== null && (yield* fileSystem.exists(worktreePath)) + ? worktreePath + : workspaceRoot; + return yield* gitWorkflow.listRefs({ ...refs, cwd }); + }); + + return WorktreeMcpService.of({ handoff, status, listRefs }); }); export const layer: Layer.Layer< WorktreeMcpService, never, | SecureRandom.SecureRandom + | FileSystem.FileSystem | ThreadManagementService.ThreadManagementService | ProjectService.ProjectService | ServerSettings.ServerSettingsService diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index c6fabe18c6fb8..2e78dd2a4f996 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -1,6 +1,5 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import * as GitWorkflow from "../../../git/GitWorkflowService.ts"; import * as Project from "../../../project/ProjectService.ts"; import { readThread, unavailable } from "../../threadAccess.ts"; import * as Effect from "effect/Effect"; @@ -30,9 +29,13 @@ const handlers = { code: "invalid_request", message: "The project was not found.", }); - const git = yield* GitWorkflow.GitWorkflowService; - return yield* git - .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot }) + const service = yield* WorktreeMcpService.WorktreeMcpService; + return yield* service + .listRefs({ + ...refs, + worktreePath: thread.worktreePath, + workspaceRoot: project.value.workspaceRoot, + }) .pipe(Effect.mapError(unavailable)); }), ), diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 81a39a5e30a42..18d6840d72291 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -10,7 +10,6 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import { Tool, Toolkit } from "effect/ai"; @@ -78,7 +77,7 @@ const WorktreeListTool = Tool.make("t3_worktree_list", { McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, ProjectService.ProjectService, - GitWorkflowService.GitWorkflowService, + WorktreeMcpService.WorktreeMcpService, ], }) .annotate(Tool.Readonly, true)