fix(worktree): type expected errors (#27296)

This commit is contained in:
Shoubhit Dash
2026-05-13 15:05:30 +05:30
committed by GitHub
parent ccf93f3523
commit 2e7cf92c8b
7 changed files with 125 additions and 62 deletions

View File

@@ -54,6 +54,22 @@ export const ToolListQuery = Schema.Struct({
})
const WorktreeList = Schema.Array(Schema.String)
const WorktreeErrorName = Schema.Union([
Schema.Literal("WorktreeNotGitError"),
Schema.Literal("WorktreeNameGenerationFailedError"),
Schema.Literal("WorktreeCreateFailedError"),
Schema.Literal("WorktreeStartCommandFailedError"),
Schema.Literal("WorktreeRemoveFailedError"),
Schema.Literal("WorktreeResetFailedError"),
Schema.Literal("WorktreeListFailedError"),
])
export class WorktreeApiError extends Schema.ErrorClass<WorktreeApiError>("WorktreeError")(
{
name: WorktreeErrorName,
data: Schema.Struct({ message: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
export const SessionListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
roots: Schema.optional(QueryBoolean),
@@ -141,6 +157,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
success: described(WorktreeList, "List of worktree directories"),
error: WorktreeApiError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.list",
@@ -152,7 +169,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
query: WorkspaceRoutingQuery,
payload: Schema.optional(Worktree.CreateInput),
success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest,
error: WorktreeApiError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.create",
@@ -164,7 +181,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
query: WorkspaceRoutingQuery,
payload: Worktree.RemoveInput,
success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest,
error: WorktreeApiError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.remove",
@@ -176,7 +193,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
query: WorkspaceRoutingQuery,
payload: Worktree.ResetInput,
success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest,
error: WorktreeApiError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.reset",

View File

@@ -12,7 +12,15 @@ import { Effect, Option } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental"
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery, WorktreeApiError } from "../groups/experimental"
function mapWorktreeError<A, R>(self: Effect.Effect<A, Worktree.Error, R>) {
return self.pipe(
Effect.mapError(
(error) => new WorktreeApiError({ name: error._tag, data: { message: error.message } }),
),
)
}
export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) =>
Effect.gen(function* () {
@@ -100,14 +108,14 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
return yield* mapWorktreeError(worktreeSvc.create(ctx.payload))
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* mapWorktreeError(worktreeSvc.remove(input.payload))
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
@@ -115,7 +123,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
yield* mapWorktreeError(worktreeSvc.reset(ctx.payload))
return true
})

View File

@@ -29,7 +29,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
status: iife(() => {
if (error instanceof Provider.ModelNotFoundError) return 400
if (error.name === "ProviderAuthValidationFailed") return 400
if (error.name.startsWith("Worktree")) return 400
return 500
}),
}),

View File

@@ -1,4 +1,3 @@
import { NamedError } from "@opencode-ai/core/util/error"
import { Global } from "@opencode-ai/core/global"
import { InstanceLayer } from "@/project/instance-layer"
import { InstanceStore } from "@/project/instance-store"
@@ -64,33 +63,48 @@ export const ResetInput = Schema.Struct({
}).annotate({ identifier: "WorktreeResetInput" })
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
export const NotGitError = NamedError.create("WorktreeNotGitError", {
export class NotGitError extends Schema.TaggedErrorClass<NotGitError>()("WorktreeNotGitError", {
message: Schema.String,
})
}) {}
export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", {
message: Schema.String,
})
export class NameGenerationFailedError extends Schema.TaggedErrorClass<NameGenerationFailedError>()(
"WorktreeNameGenerationFailedError",
{
message: Schema.String,
},
) {}
export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", {
export class CreateFailedError extends Schema.TaggedErrorClass<CreateFailedError>()("WorktreeCreateFailedError", {
message: Schema.String,
})
}) {}
export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", {
message: Schema.String,
})
export class StartCommandFailedError extends Schema.TaggedErrorClass<StartCommandFailedError>()(
"WorktreeStartCommandFailedError",
{
message: Schema.String,
},
) {}
export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", {
export class RemoveFailedError extends Schema.TaggedErrorClass<RemoveFailedError>()("WorktreeRemoveFailedError", {
message: Schema.String,
})
}) {}
export const ResetFailedError = NamedError.create("WorktreeResetFailedError", {
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("WorktreeResetFailedError", {
message: Schema.String,
})
}) {}
export const ListFailedError = NamedError.create("WorktreeListFailedError", {
export class ListFailedError extends Schema.TaggedErrorClass<ListFailedError>()("WorktreeListFailedError", {
message: Schema.String,
})
}) {}
export type Error =
| NotGitError
| NameGenerationFailedError
| CreateFailedError
| StartCommandFailedError
| RemoveFailedError
| ResetFailedError
| ListFailedError
function slugify(input: string) {
return input
@@ -121,12 +135,12 @@ function failedRemoves(...chunks: string[]) {
// ---------------------------------------------------------------------------
export interface Interface {
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info, Error>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void, Error>
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[], Error>
readonly remove: (input: RemoveInput) => Effect.Effect<boolean, Error>
readonly reset: (input: ResetInput) => Effect.Effect<boolean, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
@@ -193,7 +207,7 @@ export const layer: Layer.Layer<
return { name, directory, ...(branch ? { branch } : {}) }
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
return yield* new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
})
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: {
@@ -202,7 +216,7 @@ export const layer: Layer.Layer<
}) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
@@ -220,7 +234,7 @@ export const layer: Layer.Layer<
{ cwd: ctx.worktree },
)
if (created.code !== 0) {
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
return yield* new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
}
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
@@ -336,7 +350,7 @@ export const layer: Layer.Layer<
const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (result.code !== 0) {
throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
}
const primary = yield* canonical(ctx.worktree)
@@ -364,27 +378,27 @@ export const layer: Layer.Layer<
}
function cleanDirectory(target: string) {
return Effect.promise(() =>
import("fs/promises")
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
.catch((error) => {
const message = errorMessage(error)
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
}),
)
return Effect.tryPromise({
try: () =>
import("fs/promises").then((fsp) =>
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
),
catch: (error) =>
new RemoveFailedError({ message: errorMessage(error) || "Failed to remove git worktree directory" }),
})
}
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = yield* canonical(input.directory)
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (list.code !== 0) {
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
const entries = parseWorktreeList(list.text)
@@ -404,14 +418,14 @@ export const layer: Layer.Layer<
if (removed.code !== 0) {
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (next.code !== 0) {
throw new RemoveFailedError({
return yield* new RemoveFailedError({
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
})
}
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
if (stale?.path) {
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
return yield* new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
}
}
@@ -421,7 +435,7 @@ export const layer: Layer.Layer<
if (branch) {
const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree })
if (deleted.code !== 0) {
throw new RemoveFailedError({
return yield* new RemoveFailedError({
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
})
}
@@ -436,7 +450,7 @@ export const layer: Layer.Layer<
error: (r: GitResult) => Error,
) {
const result = yield* git(args, opts)
if (result.code !== 0) throw error(result)
if (result.code !== 0) return yield* error(result)
return result
})
@@ -511,30 +525,30 @@ export const layer: Layer.Layer<
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = yield* canonical(input.directory)
const primary = yield* canonical(ctx.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
return yield* new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (list.code !== 0) {
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
return yield* new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
return yield* new ResetFailedError({ message: "Worktree not found" })
}
const worktreePath = entry.path
const base = yield* gitSvc.defaultBranch(ctx.worktree)
if (!base) {
throw new ResetFailedError({ message: "Default branch not found" })
return yield* new ResetFailedError({ message: "Default branch not found" })
}
const sep = base.ref.indexOf("/")
@@ -556,7 +570,7 @@ export const layer: Layer.Layer<
const cleanResult = yield* sweep(worktreePath)
if (cleanResult.code !== 0) {
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
return yield* new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
}
yield* gitExpect(
@@ -579,11 +593,11 @@ export const layer: Layer.Layer<
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
if (status.code !== 0) {
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
return yield* new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
}
if (status.text.trim()) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
}
yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe(

View File

@@ -135,13 +135,17 @@ describe("Worktree", () => {
{ git: true },
)
it.instance("throws NotGitError for non-git directories", () =>
it.instance("fails with NotGitError for non-git directories", () =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(Worktree.NotGitError)
if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError")
}
}),
)
@@ -286,14 +290,18 @@ describe("Worktree", () => {
{ git: true },
)
it.instance("throws NotGitError for non-git directories", () =>
it.instance("fails with NotGitError for non-git directories", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(Worktree.NotGitError)
if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError")
}
}),
)
})

View File

@@ -171,7 +171,7 @@ function withContext<A, E>(
messages: (sessionID) =>
run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))),
todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))),
worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))),
worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))),
worktreeRemove: (directory) =>
run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)),
llmText: (value) => Effect.suspend(() => llm().text(value)),

View File

@@ -192,6 +192,23 @@ describe("experimental HttpApi", () => {
},
)
it.instance("returns declared worktree errors", () =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const response = yield* request(ExperimentalPaths.worktree, tmp.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
})
expect(response.status).toBe(400)
expect(yield* json(response)).toEqual({
name: "WorktreeNotGitError",
data: { message: "Worktrees are only supported for git projects" },
})
}),
)
it.instance(
"serves Console org switch through the default server app",
() =>