diff --git a/packages/app/src/context/prompt.test.ts b/packages/app/src/context/prompt.test.ts index 7f3909f58..7014b1e23 100644 --- a/packages/app/src/context/prompt.test.ts +++ b/packages/app/src/context/prompt.test.ts @@ -8,6 +8,7 @@ let isStructurallyEmpty: typeof import("./prompt").isStructurallyEmpty beforeAll(async () => { mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, useParams: () => ({}), })) mock.module("@opencode-ai/ui/context", () => ({ @@ -182,4 +183,3 @@ describe("isStructurallyEmpty", () => { expect(isStructurallyEmpty(DEFAULT_PROMPT, [], [image])).toBe(false) }) }) - diff --git a/packages/app/src/hooks/use-providers.test.ts b/packages/app/src/hooks/use-providers.test.ts index aab650fb0..772fe8a5f 100644 --- a/packages/app/src/hooks/use-providers.test.ts +++ b/packages/app/src/hooks/use-providers.test.ts @@ -1,6 +1,7 @@ import { expect, mock, test } from "bun:test" mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, useParams: () => ({}), })) diff --git a/packages/app/src/pages/session/session-layout.test.ts b/packages/app/src/pages/session/session-layout.test.ts index 937cf2bdb..546eb958c 100644 --- a/packages/app/src/pages/session/session-layout.test.ts +++ b/packages/app/src/pages/session/session-layout.test.ts @@ -6,6 +6,7 @@ let sessionRouteLayoutKey: typeof import("./session-layout").sessionRouteLayoutK beforeAll(async () => { mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, useParams: () => ({}), })) const mod = await import("./session-layout") diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts index b7aa8d7c4..2a5e59fde 100644 --- a/packages/app/src/pages/session/use-session-followups.test.ts +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -1,7 +1,8 @@ -import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { createRoot, createSignal } from "solid-js" import { createStore } from "solid-js/store" +import type { FollowupDraft } from "@/components/prompt-input/followup-draft" import { normalize, readPersistedAsync, readPersistedSync } from "@/utils/persist-read" import type { canSendFollowupItem as CanSendFollowupItem, @@ -25,15 +26,14 @@ let followupDraftMatchesScope: typeof FollowupDraftMatchesScope const sendFollowupCalls: unknown[] = [] let sendFollowupDraftImpl: (input: unknown) => Promise -type FollowupDraft = any - function workspaceStorage(dir: string) { const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") - let sum = 0 + let hash = 0x811c9dc5 for (let index = 0; index < dir.length; index++) { - sum = (sum + dir.charCodeAt(index) * (index + 1)) >>> 0 + hash ^= dir.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) } - return `pawwork.workspace.${head}.${sum.toString(36)}.dat` + return `pawwork.workspace.${head}.${(hash >>> 0).toString(36)}.dat` } const PersistMock = { @@ -82,6 +82,7 @@ beforeAll(async () => { workspaceStorage, }, persisted: (_target: unknown, store: unknown) => store, + removePersisted: () => undefined, })) mock.module("@/utils/id", () => ({ Identifier: { @@ -99,6 +100,10 @@ beforeAll(async () => { followupDraftMatchesScope = mod.followupDraftMatchesScope }) +afterAll(() => { + mock.restore() +}) + function deferred() { let resolve!: (value: T) => void const promise = new Promise((done) => { diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 76d5dcf49..90bd3815b 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -57,6 +57,7 @@ export namespace Git { export interface PatchOptions { readonly context?: number readonly maxOutputBytes?: number + readonly binary?: boolean } export interface Result { @@ -73,6 +74,7 @@ export namespace Git { readonly cwd: string readonly env?: Record readonly maxOutputBytes?: number + readonly stdin?: ChildProcess.CommandInput } export interface Interface { @@ -95,11 +97,14 @@ export namespace Git { readonly statsStaged: (cwd: string) => Effect.Effect readonly statsHead: (cwd: string, ref: string) => Effect.Effect readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect + readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect + readonly patchStagedAll: (cwd: string, options?: PatchOptions) => Effect.Effect readonly patchUnstaged: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly patchStaged: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly patchHead: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly statUntracked: (cwd: string, file: string) => Effect.Effect + readonly applyPatch: (cwd: string, patch: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -116,6 +121,8 @@ export namespace Git { Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const encoder = new TextEncoder() + const stdin = (text: string) => Stream.make(encoder.encode(text)) const run = Effect.fn("Git.run")( function* (args: string[], opts: Options) { @@ -123,7 +130,7 @@ export namespace Git { cwd: opts.cwd, env: opts.env, extendEnv: true, - stdin: "ignore", + stdin: opts.stdin ?? "ignore", stdout: "pipe", stderr: "pipe", }) @@ -429,9 +436,27 @@ export namespace Git { return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch }) + const binary = (options?: PatchOptions) => (options?.binary ? ["--binary"] : []) + const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { return yield* patchResult( - ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + ["diff", "--patch", ...binary(options), "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + cwd, + options, + ) + }) + + const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + return yield* patchResult( + ["diff", "--patch", ...binary(options), "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + cwd, + options, + ) + }) + + const patchStagedAll = Effect.fn("Git.patchStagedAll")(function* (cwd: string, options?: PatchOptions) { + return yield* patchResult( + ["diff", "--cached", "--patch", ...binary(options), "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, "--", "."], cwd, options, ) @@ -439,7 +464,7 @@ export namespace Git { const patchUnstaged = Effect.fn("Git.patchUnstaged")(function* (cwd: string, file: string, options?: PatchOptions) { return yield* patchResult( - ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, "--", file], + ["diff", "--patch", ...binary(options), "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, "--", file], cwd, options, ) @@ -451,6 +476,7 @@ export namespace Git { "diff", "--cached", "--patch", + ...binary(options), "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, @@ -464,7 +490,7 @@ export namespace Git { const patchHead = Effect.fn("Git.patchHead")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { return yield* patchResult( - ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "HEAD", "--", file], + ["diff", "--patch", ...binary(options), "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "HEAD", "--", file], cwd, options, ) @@ -472,7 +498,18 @@ export namespace Git { const patchUntracked = Effect.fn("Git.patchUntracked")(function* (cwd: string, file: string, options?: PatchOptions) { return yield* patchResult( - ["diff", "--no-index", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, "--", "/dev/null", file], + [ + "diff", + "--no-index", + "--patch", + ...binary(options), + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], cwd, options, ) @@ -498,6 +535,10 @@ export namespace Git { } satisfies Stat }) + const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) { + return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) }) + }) + return Service.of({ run, branch, @@ -518,11 +559,14 @@ export namespace Git { statsStaged, statsHead, patch, + patchAll, + patchStagedAll, patchUnstaged, patchStaged, patchHead, patchUntracked, statUntracked, + applyPatch, }) }), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 300f5c86b..25a17774b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -16,6 +16,7 @@ export namespace Vcs { // A single useful patch may consume the whole budget; later files then fall back to empty patches. const MAX_PATCH_BYTES = 10_000_000 const MAX_TOTAL_PATCH_BYTES = 10_000_000 + export const MAX_APPLY_PATCH_BYTES = MAX_TOTAL_PATCH_BYTES const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) @@ -52,6 +53,19 @@ export namespace Vcs { return result.text || emptyPatch(item.file) }) + const rawPatch = Effect.fnUntraced(function* (batch: PatchBatch, patch: Effect.Effect) { + const result = yield* patch + if (result.truncated) { + return yield* Effect.fail(new RawDiffError("Raw VCS diff exceeds the 10 MB output limit", "too-large")) + } + const size = Buffer.byteLength(result.text) + if (batch.total + size > MAX_TOTAL_PATCH_BYTES) { + return yield* Effect.fail(new RawDiffError("Raw VCS diff exceeds the 10 MB output limit", "too-large")) + } + batch.total += size + return result.text + }) + const staged = Effect.fnUntraced(function* (git: Git.Interface, cwd: string) { const [list, stats] = yield* Effect.all([git.diffStaged(cwd), git.statsStaged(cwd)], { concurrency: 2 }) const statMap = nums(stats) @@ -170,11 +184,78 @@ export namespace Vcs { }) export type FileDiff = z.infer + export const FileStatus = z + .object({ + file: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]), + }) + .meta({ + ref: "VcsFileStatus", + }) + export type FileStatus = z.infer + + export const ApplyInput = z.object({ + patch: z.string(), + }) + export type ApplyInput = z.infer + + export const ApplyResult = z.object({ + applied: z.boolean(), + }) + export type ApplyResult = z.infer + + export const ApplyError = z + .object({ + error: z.literal("vcs_apply_failed"), + reason: z.enum(["non-git", "not-clean", "too-large", "invalid-input"]), + message: z.string(), + }) + .meta({ + ref: "VcsApplyFailure", + }) + export type ApplyError = z.infer + + export const DiffRawError = z + .object({ + error: z.literal("vcs_diff_raw_failed"), + reason: z.literal("too-large"), + message: z.string(), + }) + .meta({ + ref: "VcsDiffRawFailure", + }) + export type DiffRawError = z.infer + + export class RawDiffError extends Error { + constructor( + message: string, + readonly reason: DiffRawError["reason"], + ) { + super(message) + this.name = "VcsRawDiffError" + } + } + + export class PatchApplyError extends Error { + constructor( + message: string, + readonly reason: ApplyError["reason"], + ) { + super(message) + this.name = "VcsPatchApplyError" + } + } + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect readonly defaultBranch: () => Effect.Effect + readonly status: () => Effect.Effect readonly diff: (mode: Mode) => Effect.Effect + readonly diffRaw: () => Effect.Effect + readonly apply: (input: ApplyInput) => Effect.Effect } interface State { @@ -234,6 +315,31 @@ export namespace Vcs { defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { return yield* InstanceState.use(state, (x) => x.root?.name) }), + status: Effect.fn("Vcs.status")(function* () { + if (Instance.project.vcs !== "git") return [] + const worktree = Instance.worktree ?? Instance.directory + const ref = (yield* git.hasHead(worktree)) ? "HEAD" : undefined + const [list, stats] = yield* Effect.all( + [git.status(worktree), ref ? git.stats(worktree, ref) : Effect.succeed([])], + { concurrency: 2 }, + ) + const statMap = nums(stats) + return yield* Effect.forEach( + list.toSorted((a, b) => a.file.localeCompare(b.file)), + (item) => + Effect.gen(function* () { + const stat = + statMap.get(item.file) ?? + (item.status === "added" ? yield* git.statUntracked(worktree, item.file) : undefined) + return { + file: item.file, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + } satisfies FileStatus + }), + ) + }), diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { const value = yield* InstanceState.get(state) if (Instance.project.vcs !== "git") return [] @@ -251,6 +357,58 @@ export namespace Vcs { if (!ref) return [] return yield* branchHead(git, Instance.directory, ref) }), + diffRaw: Effect.fn("Vcs.diffRaw")(function* () { + if (Instance.project.vcs !== "git") return "" + const worktree = Instance.worktree ?? Instance.directory + const [hasHead, status] = yield* Effect.all([git.hasHead(worktree), git.status(worktree)], { + concurrency: 2, + }) + const batch: PatchBatch = { total: 0, capped: false } + const tracked = yield* rawPatch( + batch, + hasHead + ? git.patchAll(worktree, "HEAD", { binary: true, maxOutputBytes: MAX_TOTAL_PATCH_BYTES }) + : git.patchStagedAll(worktree, { binary: true, maxOutputBytes: MAX_TOTAL_PATCH_BYTES }), + ) + const untracked = yield* Effect.forEach( + status.filter((item) => item.code === "??"), + (item) => + rawPatch( + batch, + git.patchUntracked(worktree, item.file, { binary: true, maxOutputBytes: MAX_TOTAL_PATCH_BYTES }), + ), + { concurrency: 1 }, + ) + const initialWorktree = hasHead + ? [] + : yield* Effect.forEach( + status.filter((item) => item.code !== "??" && item.code[1] !== " "), + (item) => + rawPatch( + batch, + git.patchUnstaged(worktree, item.file, { binary: true, maxOutputBytes: MAX_TOTAL_PATCH_BYTES }), + ), + { concurrency: 1 }, + ) + const patch = [tracked, ...initialWorktree, ...untracked].filter(Boolean).join("\n") + if (Buffer.byteLength(patch) > MAX_TOTAL_PATCH_BYTES) { + return yield* Effect.fail(new RawDiffError("Raw VCS diff exceeds the 10 MB output limit", "too-large")) + } + return patch + }), + apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) { + if (Buffer.byteLength(input.patch) > MAX_APPLY_PATCH_BYTES) { + return yield* Effect.fail(new PatchApplyError("Patch exceeds the 10 MB input limit", "too-large")) + } + if (Instance.project.vcs !== "git") { + return yield* Effect.fail(new PatchApplyError("Patch can't be applied because the project is not git-based", "non-git")) + } + const applied = yield* git.applyPatch(Instance.worktree ?? Instance.directory, input.patch) + if (applied.exitCode !== 0) { + return yield* Effect.fail(new PatchApplyError("Patch can't be applied", "not-clean")) + } + return { applied: true } + }), }) }), ) @@ -271,7 +429,19 @@ export namespace Vcs { return runPromise((svc) => svc.defaultBranch()) } + export async function status() { + return runPromise((svc) => svc.status()) + } + export async function diff(mode: Mode) { return runPromise((svc) => svc.diff(mode)) } + + export async function diffRaw() { + return runPromise((svc) => svc.diffRaw()) + } + + export async function apply(input: ApplyInput) { + return runPromise((svc) => svc.apply(input)) + } } diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index a5ba60992..b2dca8e5d 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -27,6 +27,32 @@ import { EventRoutes } from "./event" import { MemoryRoutes } from "./memory" import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" +import { jsonBodyLimit } from "./json-body-limit" + +const applyPatchTooLarge = () => + ({ + error: "vcs_apply_failed", + reason: "too-large", + message: "Patch exceeds the 10 MB input limit", + }) satisfies Vcs.ApplyError + +const applyPatchInvalidInput = () => + ({ + error: "vcs_apply_failed", + reason: "invalid-input", + message: "Patch request body must be valid JSON with a string patch", + }) satisfies Vcs.ApplyError + +const applyJsonEnvelopeBytes = Buffer.byteLength(JSON.stringify({ patch: "" })) +// A JSON string can encode one decoded byte as six ASCII bytes (for example "\u000a"). +const maxJsonStringEscapeRatio = 6 +const applyJsonBodyMaxBytes = Vcs.MAX_APPLY_PATCH_BYTES * maxJsonStringEscapeRatio + applyJsonEnvelopeBytes + +const applyPatchBodyLimit = jsonBodyLimit({ + maxBytes: applyJsonBodyMaxBytes, + tooLarge: (c) => c.json(applyPatchTooLarge(), 413), + invalidJson: (c) => c.json(applyPatchInvalidInput(), 400), +}) export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => new Hono() @@ -174,6 +200,125 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => return c.json(await Vcs.diff(c.req.valid("query").mode)) }, ) + .get( + "/vcs/status", + describeRoute({ + summary: "Get VCS status", + description: "Retrieve working tree file status summaries for the current project.", + operationId: "vcs.status", + responses: { + 200: { + description: "VCS status", + content: { + "application/json": { + schema: resolver(Vcs.FileStatus.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Vcs.status()) + }, + ) + .get( + "/vcs/diff/raw", + describeRoute({ + summary: "Get raw VCS diff", + description: "Retrieve the current git diff as raw patch text.", + operationId: "vcs.diffRaw", + responses: { + 200: { + description: "Raw VCS diff", + content: { + "text/plain": { + schema: resolver(z.string()), + }, + }, + }, + 413: { + description: "Raw VCS diff failure", + content: { + "application/json": { + schema: resolver(Vcs.DiffRawError), + }, + }, + }, + }, + }), + async (c) => { + try { + c.header("content-type", "text/plain; charset=UTF-8") + return c.text(await Vcs.diffRaw()) + } catch (error) { + if (error instanceof Vcs.RawDiffError) { + const body = { + error: "vcs_diff_raw_failed", + reason: error.reason, + message: error.message, + } satisfies Vcs.DiffRawError + return c.json(body, 413) + } + throw error + } + }, + ) + .post( + "/vcs/apply", + describeRoute({ + summary: "Apply VCS patch", + description: "Apply a git patch to the current project.", + operationId: "vcs.apply", + responses: { + 200: { + description: "Patch apply result", + content: { + "application/json": { + schema: resolver(Vcs.ApplyResult), + }, + }, + }, + 400: { + description: "VCS patch apply failure", + content: { + "application/json": { + schema: resolver(Vcs.ApplyError), + }, + }, + }, + 413: { + description: "VCS patch apply failure", + content: { + "application/json": { + schema: resolver(Vcs.ApplyError), + }, + }, + }, + }, + }), + applyPatchBodyLimit, + validator("json", Vcs.ApplyInput, (result, c) => { + if (!result.success) return c.json(applyPatchInvalidInput(), 400) + }), + async (c) => { + try { + return c.json(await Vcs.apply(c.req.valid("json"))) + } catch (error) { + if (error instanceof Vcs.PatchApplyError) { + const body = + error.reason === "too-large" + ? applyPatchTooLarge() + : ({ + error: "vcs_apply_failed", + reason: error.reason, + message: error.message, + } satisfies Vcs.ApplyError) + return c.json(body, error.reason === "too-large" ? 413 : 400) + } + throw error + } + }, + ) .get( "/command", describeRoute({ diff --git a/packages/opencode/src/server/instance/json-body-limit.ts b/packages/opencode/src/server/instance/json-body-limit.ts new file mode 100644 index 000000000..aecb76796 --- /dev/null +++ b/packages/opencode/src/server/instance/json-body-limit.ts @@ -0,0 +1,72 @@ +import type { Context, MiddlewareHandler, Next } from "hono" + +type JsonBodyLimitInput = { + maxBytes: number + tooLarge: (c: Context) => Response | Promise + invalidJson: (c: Context) => Response | Promise +} + +export function jsonBodyLimit(input: JsonBodyLimitInput): MiddlewareHandler { + return async (c, next) => { + const contentLength = c.req.header("content-length") + if (contentLength && Number.parseInt(contentLength, 10) > input.maxBytes) { + return input.tooLarge(c) + } + + return limitJsonBody(c, next, input) + } +} + +async function limitJsonBody(c: Context, next: Next, input: JsonBodyLimitInput) { + const body = c.req.raw.body + if (!body) { + if (isJsonRequest(c)) return input.invalidJson(c) + return next() + } + + const reader = body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > input.maxBytes) { + await reader.cancel().catch(() => undefined) + return input.tooLarge(c) + } + chunks.push(value) + } + + const bytes = concatChunks(chunks, total) + c.req.raw = new Request(c.req.raw, { + body: bytes, + duplex: "half", + } as RequestInit & { duplex: "half" }) + + if (isJsonRequest(c) && !hasValidJson(bytes)) return input.invalidJson(c) + return next() +} + +function concatChunks(chunks: Uint8Array[], total: number) { + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +function isJsonRequest(c: Context) { + return c.req.header("content-type")?.includes("json") === true +} + +function hasValidJson(bytes: Uint8Array) { + try { + JSON.parse(new TextDecoder().decode(bytes)) + return true + } catch { + return false + } +} diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 165ab05e2..9cf216cd2 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,13 +1,15 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" import { GlobalBus } from "../../src/bus/global" import { Vcs } from "../../src/project/vcs" +import { testEffect } from "../lib/effect" // Skip in CI — native @parcel/watcher binding needed const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -29,17 +31,25 @@ async function withVcs(directory: string, body: () => Promise) { } function withVcsOnly(directory: string, body: () => Promise) { - return Instance.provide({ - directory, - fn: async () => { - Vcs.init() - await body() - }, - }) + return provideInstance(directory)( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + yield* vcs.init() + yield* Effect.promise(body) + }), + ) } type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" +const vcsIt = testEffect(Vcs.defaultLayer) + +type TmpdirOptions = Parameters[0] +const scopedTmpdir = (options?: TmpdirOptions) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ function nextBranchUpdate(directory: string, timeout = 10_000) { @@ -134,153 +144,301 @@ describe("Vcs diff", () => { await Instance.disposeAll() }) - test("defaultBranch() falls back to main", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async () => { - const branch = await Vcs.defaultBranch() - expect(branch).toBe("main") - }) - }) - - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async () => { - const branch = await Vcs.defaultBranch() - expect(branch).toBe("trunk") - }) - }) - - test("detects current branch from the active worktree", async () => { - await using tmp = await tmpdir({ git: true }) - await using wt = await tmpdir() - await $`git branch -M main`.cwd(tmp.path).quiet() - const dir = path.join(wt.path, "feature") - await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() - - await withVcsOnly(dir, async () => { - const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) - expect(branch).toBe("feature/test") - expect(base).toBe("main") - }) - }) - - test("diff('unstaged') returns unstaged and untracked changes only", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") - await $`git add tracked.txt`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "tracked.txt"), "changed\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "staged.txt"), "staged\n", "utf-8") - await $`git add staged.txt`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "untracked.txt"), "untracked\n", "utf-8") - - await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("unstaged") - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ file: "tracked.txt", status: "modified" }), - expect.objectContaining({ file: "untracked.txt", status: "added" }), - ]), - ) - expect(diff.find((item) => item.file === "tracked.txt")?.patch).toContain("diff --git") - expect(diff.find((item) => item.file === "untracked.txt")?.patch).toContain("+untracked") - expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "staged.txt" })])) - }) - }) - - test("diff('unstaged') handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") - - await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("unstaged") - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: weird, - status: "added", - }), - ]), - ) - }) - }) - - test("diff('staged') returns staged changes only", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") - await $`git add tracked.txt`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - - await fs.writeFile(path.join(tmp.path, "staged.txt"), "staged\n", "utf-8") - await $`git add staged.txt`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "unstaged.txt"), "unstaged\n", "utf-8") - - await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("staged") - expect(diff).toEqual(expect.arrayContaining([expect.objectContaining({ file: "staged.txt", status: "added" })])) - expect(diff.find((item) => item.file === "staged.txt")?.patch).toContain("+staged") - expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "unstaged.txt" })])) - }) - }) - - test("diff('staged') returns staged files before the first commit", async () => { - await using tmp = await tmpdir() - await $`git init`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "first.txt"), "first\n", "utf-8") - await $`git add first.txt`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("staged") - expect(diff).toEqual(expect.arrayContaining([expect.objectContaining({ file: "first.txt", status: "added" })])) - }) - }) - - test("diff('branch') returns committed branch changes without staged, unstaged, or untracked files", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "branch.txt"), "branch\n", "utf-8") - await $`git add branch.txt`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() - - await fs.writeFile(path.join(tmp.path, "staged.txt"), "staged\n", "utf-8") - await $`git add staged.txt`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "unstaged.txt"), "unstaged\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "untracked.txt"), "untracked\n", "utf-8") - - await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("branch") - expect(diff).toEqual(expect.arrayContaining([expect.objectContaining({ file: "branch.txt", status: "added" })])) - expect(diff.find((item) => item.file === "branch.txt")?.patch).toContain("+branch") - expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "staged.txt" })])) - expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "unstaged.txt" })])) - expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "untracked.txt" })])) - }) - }) - - test("diff('branch') returns changes against default branch", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("branch") - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "branch.txt", - status: "added", - }), - ]), - ) - }) - }) + vcsIt.live("status() returns tracked, staged, and untracked file summaries", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "changed\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "staged.txt"), "staged\n", "utf-8") + await $`git add staged.txt`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "untracked.txt"), "untracked\n", "utf-8") + }) + + yield* withVcsOnly(tmp.path, async () => { + const status = await Vcs.status() + expect(status).toEqual([ + { file: "staged.txt", additions: 1, deletions: 0, status: "added" }, + { file: "tracked.txt", additions: 1, deletions: 1, status: "modified" }, + { file: "untracked.txt", additions: 1, deletions: 0, status: "added" }, + ]) + }) + }), + ) + + vcsIt.live("defaultBranch() falls back to main", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await $`git branch -M main`.cwd(tmp.path).quiet() + }) + + yield* withVcsOnly(tmp.path, async () => { + const branch = await Vcs.defaultBranch() + expect(branch).toBe("main") + }) + }), + ) + + vcsIt.live("defaultBranch() uses init.defaultBranch when available", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await $`git branch -M trunk`.cwd(tmp.path).quiet() + await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() + }) + + yield* withVcsOnly(tmp.path, async () => { + const branch = await Vcs.defaultBranch() + expect(branch).toBe("trunk") + }) + }), + ) + + vcsIt.live("detects current branch from the active worktree", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + const wt = yield* scopedTmpdir() + const dir = path.join(wt.path, "feature") + yield* Effect.promise(async () => { + await $`git branch -M main`.cwd(tmp.path).quiet() + await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() + }) + + yield* withVcsOnly(dir, async () => { + const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) + expect(branch).toBe("feature/test") + expect(base).toBe("main") + }) + }), + ) + + vcsIt.live("diff('unstaged') returns unstaged and untracked changes only", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "changed\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "staged.txt"), "staged\n", "utf-8") + await $`git add staged.txt`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "untracked.txt"), "untracked\n", "utf-8") + }) + + yield* withVcsOnly(tmp.path, async () => { + const diff = await Vcs.diff("unstaged") + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "tracked.txt", status: "modified" }), + expect.objectContaining({ file: "untracked.txt", status: "added" }), + ]), + ) + expect(diff.find((item) => item.file === "tracked.txt")?.patch).toContain("diff --git") + expect(diff.find((item) => item.file === "untracked.txt")?.patch).toContain("+untracked") + expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "staged.txt" })])) + }) + }), + ) + + vcsIt.live("diffRaw() returns a patch with tracked and untracked changes", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "changed\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "untracked.txt"), "new\n", "utf-8") + }) + + yield* withVcsOnly(tmp.path, async () => { + const patch = await Vcs.diffRaw() + expect(patch).toContain("diff --git a/tracked.txt b/tracked.txt") + expect(patch).toContain("-original") + expect(patch).toContain("+changed") + expect(patch).toContain("diff --git a/untracked.txt b/untracked.txt") + expect(patch).toContain("+new") + }) + }), + ) + + vcsIt.live("apply() applies a valid patch", () => + Effect.gen(function* () { + const source = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await fs.writeFile(path.join(source.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(source.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(source.path).quiet() + await fs.writeFile(path.join(source.path, "tracked.txt"), "changed\n", "utf-8") + }) + + let patch = "" + yield* withVcsOnly(source.path, async () => { + patch = await Vcs.diffRaw() + }) + + const target = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await fs.writeFile(path.join(target.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(target.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(target.path).quiet() + }) + + yield* withVcsOnly(target.path, async () => { + await expect(Vcs.apply({ patch })).resolves.toEqual({ applied: true }) + await expect(fs.readFile(path.join(target.path, "tracked.txt"), "utf-8")).resolves.toBe("changed\n") + }) + }), + ) + + vcsIt.live("apply() rejects non-git directories", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir() + + yield* withVcsOnly(tmp.path, async () => { + await expect(Vcs.apply({ patch: "diff --git a/file.txt b/file.txt\n" })).rejects.toMatchObject({ + reason: "non-git", + }) + }) + }), + ) + + vcsIt.live("apply() rejects patches that do not apply cleanly", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "different\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + }) + + const patch = [ + "diff --git a/tracked.txt b/tracked.txt", + "index 5626abf..21fb1ec 100644", + "--- a/tracked.txt", + "+++ b/tracked.txt", + "@@ -1 +1 @@", + "-original", + "+changed", + "", + ].join("\n") + + yield* withVcsOnly(tmp.path, async () => { + await expect(Vcs.apply({ patch })).rejects.toMatchObject({ + reason: "not-clean", + }) + await expect(fs.readFile(path.join(tmp.path, "tracked.txt"), "utf-8")).resolves.toBe("different\n") + }) + }), + ) + + vcsIt.live("diff('unstaged') handles special filenames", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")) + + yield* withVcsOnly(tmp.path, async () => { + const diff = await Vcs.diff("unstaged") + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: weird, + status: "added", + }), + ]), + ) + }) + }), + ) + + vcsIt.live("diff('staged') returns staged changes only", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "staged.txt"), "staged\n", "utf-8") + await $`git add staged.txt`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "unstaged.txt"), "unstaged\n", "utf-8") + }) + + yield* withVcsOnly(tmp.path, async () => { + const diff = await Vcs.diff("staged") + expect(diff).toEqual(expect.arrayContaining([expect.objectContaining({ file: "staged.txt", status: "added" })])) + expect(diff.find((item) => item.file === "staged.txt")?.patch).toContain("+staged") + expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "unstaged.txt" })])) + }) + }), + ) + + vcsIt.live("diff('staged') returns staged files before the first commit", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir() + yield* Effect.promise(async () => { + await $`git init`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "first.txt"), "first\n", "utf-8") + await $`git add first.txt`.cwd(tmp.path).quiet() + }) + + yield* withVcsOnly(tmp.path, async () => { + const diff = await Vcs.diff("staged") + expect(diff).toEqual(expect.arrayContaining([expect.objectContaining({ file: "first.txt", status: "added" })])) + }) + }), + ) + + vcsIt.live("diff('branch') returns committed branch changes without staged, unstaged, or untracked files", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await $`git branch -M main`.cwd(tmp.path).quiet() + await $`git checkout -b feature/test`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "branch.txt"), "branch\n", "utf-8") + await $`git add branch.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "staged.txt"), "staged\n", "utf-8") + await $`git add staged.txt`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "unstaged.txt"), "unstaged\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "untracked.txt"), "untracked\n", "utf-8") + }) + + yield* withVcsOnly(tmp.path, async () => { + const diff = await Vcs.diff("branch") + expect(diff).toEqual(expect.arrayContaining([expect.objectContaining({ file: "branch.txt", status: "added" })])) + expect(diff.find((item) => item.file === "branch.txt")?.patch).toContain("+branch") + expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "staged.txt" })])) + expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "unstaged.txt" })])) + expect(diff).not.toEqual(expect.arrayContaining([expect.objectContaining({ file: "untracked.txt" })])) + }) + }), + ) + + vcsIt.live("diff('branch') returns changes against default branch", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(async () => { + await $`git branch -M main`.cwd(tmp.path).quiet() + await $`git checkout -b feature/test`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() + }) + + yield* withVcsOnly(tmp.path, async () => { + const diff = await Vcs.diff("branch") + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "branch.txt", + status: "added", + }), + ]), + ) + }) + }), + ) }) diff --git a/packages/opencode/test/server/vcs-routes.test.ts b/packages/opencode/test/server/vcs-routes.test.ts new file mode 100644 index 000000000..efe57e0cd --- /dev/null +++ b/packages/opencode/test/server/vcs-routes.test.ts @@ -0,0 +1,635 @@ +import { $ } from "bun" +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Vcs } from "../../src/project/vcs" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() + await resetDatabase() +}) + +describe("VCS routes", () => { + const measureUntrackedPatch = async (cwd: string, file: string) => { + const text = + await $`git diff --no-index --patch --binary --no-ext-diff --no-renames --unified=${2_147_483_647} -- /dev/null ${file}` + .cwd(cwd) + .nothrow() + .text() + return Buffer.byteLength(text) + } + + test("documents apply failure reasons in OpenAPI", async () => { + const spec = await Server.openapi() + const badRequest = spec.paths?.["/vcs/apply"]?.post?.responses?.["400"] + const tooLarge = spec.paths?.["/vcs/apply"]?.post?.responses?.["413"] + if (!badRequest || "$ref" in badRequest) throw new Error("expected inline apply failure response") + if (!tooLarge || "$ref" in tooLarge) throw new Error("expected inline apply size failure response") + const schema = badRequest.content?.["application/json"]?.schema + + expect(schema).toEqual({ + $ref: "#/components/schemas/VcsApplyFailure", + }) + expect(tooLarge.content?.["application/json"]?.schema).toEqual(schema) + expect(spec.components?.schemas?.VcsApplyFailure).toMatchObject({ + properties: { + error: { + const: "vcs_apply_failed", + }, + reason: { + enum: ["non-git", "not-clean", "too-large", "invalid-input"], + }, + message: { + type: "string", + }, + }, + required: ["error", "reason", "message"], + }) + }) + + test("returns typed apply failures for invalid apply request bodies", async () => { + await using tmp = await tmpdir() + const cases = [ + { + name: "missing patch", + body: JSON.stringify({}), + }, + { + name: "non-string patch", + body: JSON.stringify({ patch: 1 }), + }, + { + name: "invalid JSON", + body: "{", + }, + { + name: "empty JSON body", + body: undefined, + }, + ] + + for (const item of cases) { + const response = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": tmp.path, + }, + body: item.body, + }) + + expect(response.status, item.name).toBe(400) + expect(await response.json(), item.name).toEqual({ + error: "vcs_apply_failed", + reason: "invalid-input", + message: "Patch request body must be valid JSON with a string patch", + }) + } + }) + + test("documents raw diff size failures in OpenAPI", async () => { + const spec = await Server.openapi() + const response = spec.paths?.["/vcs/diff/raw"]?.get?.responses?.["413"] + if (!response || "$ref" in response) throw new Error("expected inline raw diff failure response") + const schema = response.content?.["application/json"]?.schema + + expect(schema).toEqual({ + $ref: "#/components/schemas/VcsDiffRawFailure", + }) + expect(spec.components?.schemas?.VcsDiffRawFailure).toMatchObject({ + properties: { + error: { + const: "vcs_diff_raw_failed", + }, + reason: { + const: "too-large", + }, + message: { + type: "string", + }, + }, + required: ["error", "reason", "message"], + }) + }) + + test("returns working tree status summaries", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "changed\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "untracked.txt"), "new\n", "utf-8") + + const response = await Server.Default().app.request("/vcs/status", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual([ + { file: "tracked.txt", additions: 1, deletions: 1, status: "modified" }, + { file: "untracked.txt", additions: 1, deletions: 0, status: "added" }, + ]) + }) + + test("returns subdirectory untracked files in status summaries", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "sub")) + await fs.writeFile(path.join(tmp.path, "sub", "untracked.txt"), "one\ntwo\n", "utf-8") + + const response = await Server.Default().app.request("/vcs/status", { + headers: { + "x-opencode-directory": path.join(tmp.path, "sub"), + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual([ + { file: "sub/untracked.txt", additions: 2, deletions: 0, status: "added" }, + ]) + }) + + test("returns raw patch text", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "changed\n", "utf-8") + + const response = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/plain") + expect(await response.text()).toContain("diff --git a/tracked.txt b/tracked.txt") + }) + + test("returns staged files before the first commit in raw patch text", async () => { + await using tmp = await tmpdir() + await $`git init`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "first.txt"), "first\n", "utf-8") + await $`git add first.txt`.cwd(tmp.path).quiet() + + const response = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const patch = await response.text() + expect(patch).toContain("diff --git a/first.txt b/first.txt") + expect(patch).toContain("+first") + }) + + test("round-trips staged then modified files before the first commit", async () => { + await using source = await tmpdir() + await $`git init`.cwd(source.path).quiet() + await fs.writeFile(path.join(source.path, "first.txt"), "staged\n", "utf-8") + await $`git add first.txt`.cwd(source.path).quiet() + await fs.writeFile(path.join(source.path, "first.txt"), "final\n", "utf-8") + + const diff = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": source.path, + }, + }) + expect(diff.status).toBe(200) + const patch = await diff.text() + + await using target = await tmpdir() + await $`git init`.cwd(target.path).quiet() + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": target.path, + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + await expect(fs.readFile(path.join(target.path, "first.txt"), "utf-8")).resolves.toBe("final\n") + }) + + test("round-trips subdirectory untracked files through raw diff and apply", async () => { + await using source = await tmpdir({ git: true }) + await fs.mkdir(path.join(source.path, "sub")) + await fs.writeFile(path.join(source.path, "sub", "untracked.txt"), "new\n", "utf-8") + + const diff = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": path.join(source.path, "sub"), + }, + }) + expect(diff.status).toBe(200) + const patch = await diff.text() + expect(patch).toContain("diff --git a/sub/untracked.txt b/sub/untracked.txt") + + await using target = await tmpdir() + await $`git init`.cwd(target.path).quiet() + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": target.path, + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + await expect(fs.readFile(path.join(target.path, "sub", "untracked.txt"), "utf-8")).resolves.toBe("new\n") + }) + + test("round-trips subdirectory staged then modified files before the first commit", async () => { + await using source = await tmpdir() + await $`git init`.cwd(source.path).quiet() + await fs.mkdir(path.join(source.path, "sub")) + await fs.writeFile(path.join(source.path, "sub", "first.txt"), "staged\n", "utf-8") + await $`git add sub/first.txt`.cwd(source.path).quiet() + await fs.writeFile(path.join(source.path, "sub", "first.txt"), "final\n", "utf-8") + + const diff = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": path.join(source.path, "sub"), + }, + }) + expect(diff.status).toBe(200) + const patch = await diff.text() + + await using target = await tmpdir() + await $`git init`.cwd(target.path).quiet() + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": target.path, + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + await expect(fs.readFile(path.join(target.path, "sub", "first.txt"), "utf-8")).resolves.toBe("final\n") + }) + + test( + "rejects raw tracked diffs beyond the patch budget", + async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "large.txt"), "small\n", "utf-8") + await $`git add large.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "large.txt"), `${"x".repeat(10_100_000)}\n`, "utf-8") + + const response = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(413) + expect(response.headers.get("content-type")).toContain("application/json") + expect(await response.json()).toEqual({ + error: "vcs_diff_raw_failed", + reason: "too-large", + message: "Raw VCS diff exceeds the 10 MB output limit", + }) + }, + 20_000, + ) + + test( + "rejects raw untracked diffs beyond the patch budget", + async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "large.txt"), `${"x".repeat(10_100_000)}\n`, "utf-8") + + const response = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: "vcs_diff_raw_failed", + reason: "too-large", + message: "Raw VCS diff exceeds the 10 MB output limit", + }) + }, + 20_000, + ) + + test( + "rejects raw diffs beyond the combined patch budget", + async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "one.txt"), `${"x".repeat(5_100_000)}\n`, "utf-8") + await fs.writeFile(path.join(tmp.path, "two.txt"), `${"y".repeat(5_100_000)}\n`, "utf-8") + + const response = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: "vcs_diff_raw_failed", + reason: "too-large", + message: "Raw VCS diff exceeds the 10 MB output limit", + }) + }, + 20_000, + ) + + test( + "rejects raw diffs when patch separators exceed the combined budget", + async () => { + const maxPatchBytes = 10_000_000 + await using tmp = await tmpdir({ git: true }) + const first = "first.txt" + const second = "second.txt" + + await fs.writeFile(path.join(tmp.path, first), "x\n", "utf-8") + await fs.writeFile(path.join(tmp.path, second), "y\n", "utf-8") + const firstOverhead = (await measureUntrackedPatch(tmp.path, first)) - Buffer.byteLength("x\n") + const secondOverhead = (await measureUntrackedPatch(tmp.path, second)) - Buffer.byteLength("y\n") + + const firstPatchBytes = 5_000_000 + const firstContentBytes = firstPatchBytes - firstOverhead + const secondContentBytes = maxPatchBytes - firstPatchBytes - secondOverhead + await fs.writeFile(path.join(tmp.path, first), `${"x".repeat(firstContentBytes - 1)}\n`, "utf-8") + await fs.writeFile(path.join(tmp.path, second), `${"y".repeat(secondContentBytes - 1)}\n`, "utf-8") + + expect((await measureUntrackedPatch(tmp.path, first)) + (await measureUntrackedPatch(tmp.path, second))).toBe( + maxPatchBytes, + ) + + const response = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: "vcs_diff_raw_failed", + reason: "too-large", + message: "Raw VCS diff exceeds the 10 MB output limit", + }) + }, + 20_000, + ) + + test("rejects oversized apply patches before git apply", async () => { + await using tmp = await tmpdir() + + const response = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": tmp.path, + }, + body: JSON.stringify({ patch: "x".repeat(10_000_001) }), + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: "vcs_apply_failed", + reason: "too-large", + message: "Patch exceeds the 10 MB input limit", + }) + }) + + test("rejects oversized apply request bodies before JSON validation", async () => { + await using tmp = await tmpdir() + const apply = spyOn(Vcs, "apply") + const maxEncodedBodyBytes = Vcs.MAX_APPLY_PATCH_BYTES * 6 + Buffer.byteLength(JSON.stringify({ patch: "" })) + + try { + const response = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-length": String(maxEncodedBodyBytes + 1), + "content-type": "application/json", + "x-opencode-directory": tmp.path, + }, + body: JSON.stringify({ patch: "" }), + }) + + expect(response.status).toBe(413) + expect(apply).not.toHaveBeenCalled() + expect(await response.json()).toEqual({ + error: "vcs_apply_failed", + reason: "too-large", + message: "Patch exceeds the 10 MB input limit", + }) + } finally { + apply.mockRestore() + } + }) + + test("accepts escaped JSON bodies when decoded apply patches are within the byte limit", async () => { + await using tmp = await tmpdir() + const patch = "\n".repeat(5_000_001) + expect(Buffer.byteLength(patch)).toBeLessThanOrEqual(Vcs.MAX_APPLY_PATCH_BYTES) + const body = JSON.stringify({ patch }) + expect(Buffer.byteLength(body)).toBeGreaterThan(Vcs.MAX_APPLY_PATCH_BYTES + Buffer.byteLength(JSON.stringify({ patch: "" }))) + + const apply = spyOn(Vcs, "apply").mockResolvedValue({ applied: true }) + try { + const response = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-length": String(Buffer.byteLength(body)), + "content-type": "application/json", + "x-opencode-directory": tmp.path, + }, + body, + }) + + expect(response.status).toBe(200) + expect(apply).toHaveBeenCalledWith({ patch }) + expect(await response.json()).toEqual({ applied: true }) + } finally { + apply.mockRestore() + } + }) + + test("applies a patch and reports apply failures", async () => { + await using source = await tmpdir({ git: true }) + await fs.writeFile(path.join(source.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(source.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(source.path).quiet() + await fs.writeFile(path.join(source.path, "tracked.txt"), "changed\n", "utf-8") + const sourcePatch = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": source.path, + }, + }) + const patch = await sourcePatch.text() + + await using target = await tmpdir({ git: true }) + await fs.writeFile(path.join(target.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(target.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(target.path).quiet() + + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": target.path, + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + expect(await applied.json()).toEqual({ applied: true }) + await expect(fs.readFile(path.join(target.path, "tracked.txt"), "utf-8")).resolves.toBe("changed\n") + + const failed = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": target.path, + }, + body: JSON.stringify({ patch }), + }) + + expect(failed.status).toBe(400) + expect(await failed.json()).toMatchObject({ + error: "vcs_apply_failed", + reason: "not-clean", + }) + }) + + test("applies root-relative patches from subdirectory requests", async () => { + await using source = await tmpdir({ git: true }) + await fs.mkdir(path.join(source.path, "sub")) + await fs.writeFile(path.join(source.path, "outside.txt"), "outside\n", "utf-8") + await fs.writeFile(path.join(source.path, "sub", "inside.txt"), "inside\n", "utf-8") + const sourcePatch = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": source.path, + }, + }) + const patch = await sourcePatch.text() + + await using target = await tmpdir({ git: true }) + await fs.mkdir(path.join(target.path, "sub")) + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": path.join(target.path, "sub"), + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + expect(await applied.json()).toEqual({ applied: true }) + await expect(fs.readFile(path.join(target.path, "outside.txt"), "utf-8")).resolves.toBe("outside\n") + await expect(fs.readFile(path.join(target.path, "sub", "inside.txt"), "utf-8")).resolves.toBe("inside\n") + }) + + test("applies root-only patches from subdirectory requests", async () => { + await using source = await tmpdir({ git: true }) + await fs.writeFile(path.join(source.path, "outside.txt"), "outside\n", "utf-8") + const sourcePatch = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": source.path, + }, + }) + const patch = await sourcePatch.text() + + await using target = await tmpdir({ git: true }) + await fs.mkdir(path.join(target.path, "sub")) + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": path.join(target.path, "sub"), + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + expect(await applied.json()).toEqual({ applied: true }) + await expect(fs.readFile(path.join(target.path, "outside.txt"), "utf-8")).resolves.toBe("outside\n") + }) + + test("round-trips binary file changes through raw diff and apply", async () => { + const original = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]) + const changed = Buffer.from([0, 1, 2, 9, 10, 11, 12, 13]) + + await using source = await tmpdir({ git: true }) + await fs.writeFile(path.join(source.path, "binary.dat"), original) + await $`git add binary.dat`.cwd(source.path).quiet() + await $`git commit --no-gpg-sign -m "add binary"`.cwd(source.path).quiet() + await fs.writeFile(path.join(source.path, "binary.dat"), changed) + + const diff = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": source.path, + }, + }) + expect(diff.status).toBe(200) + const patch = await diff.text() + expect(patch).toContain("GIT binary patch") + + await using target = await tmpdir({ git: true }) + await fs.writeFile(path.join(target.path, "binary.dat"), original) + await $`git add binary.dat`.cwd(target.path).quiet() + await $`git commit --no-gpg-sign -m "add binary"`.cwd(target.path).quiet() + + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": target.path, + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + expect(Buffer.from(await fs.readFile(path.join(target.path, "binary.dat")))).toEqual(changed) + }) + + test("round-trips added binary files through raw diff and apply", async () => { + const content = Buffer.from([0, 8, 16, 24, 32, 40, 48, 56]) + + await using source = await tmpdir({ git: true }) + await fs.writeFile(path.join(source.path, "binary.dat"), content) + + const diff = await Server.Default().app.request("/vcs/diff/raw", { + headers: { + "x-opencode-directory": source.path, + }, + }) + expect(diff.status).toBe(200) + const patch = await diff.text() + expect(patch).toContain("GIT binary patch") + + await using target = await tmpdir({ git: true }) + const applied = await Server.Default().app.request("/vcs/apply", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": target.path, + }, + body: JSON.stringify({ patch }), + }) + + expect(applied.status).toBe(200) + expect(Buffer.from(await fs.readFile(path.join(target.path, "binary.dat")))).toEqual(content) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6b1bb6629..e861c0a85 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -180,8 +180,13 @@ import type { ToolIdsResponses, ToolListErrors, ToolListResponses, + VcsApplyErrors, + VcsApplyResponses, + VcsDiffRawErrors, + VcsDiffRawResponses, VcsDiffResponses, VcsGetResponses, + VcsStatusResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, @@ -4031,6 +4036,103 @@ export class Vcs extends HeyApiClient { ...params, }) } + + /** + * Get VCS status + * + * Retrieve working tree file status summaries for the current project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/status", + ...options, + ...params, + }) + } + + /** + * Get raw VCS diff + * + * Retrieve the current git diff as raw patch text. + */ + public diffRaw( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff/raw", + ...options, + ...params, + }) + } + + /** + * Apply VCS patch + * + * Apply a git patch to the current project. + */ + public apply( + parameters?: { + directory?: string + workspace?: string + patch?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "patch" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/apply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6b69f35f6..1d70195a1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -18,13 +18,6 @@ export type EventGlobalDisposed = { } } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventInstallationUpdated = { type: "installation.updated" properties: { @@ -39,6 +32,13 @@ export type EventInstallationUpdateAvailable = { } } +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -1087,9 +1087,9 @@ export type EventSessionDeleted = { export type Event = | EventServerConnected | EventGlobalDisposed - | EventServerInstanceDisposed | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventServerInstanceDisposed | EventLspClientDiagnostics | EventSessionStatus | EventSessionIdle @@ -2276,6 +2276,25 @@ export type VcsFileDiff = { status?: "added" | "deleted" | "modified" } +export type VcsFileStatus = { + file: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + +export type VcsDiffRawFailure = { + error: "vcs_diff_raw_failed" + reason: "too-large" + message: string +} + +export type VcsApplyFailure = { + error: "vcs_apply_failed" + reason: "non-git" | "not-clean" | "too-large" | "invalid-input" + message: string +} + export type Command = { name: string description?: string @@ -5894,6 +5913,89 @@ export type VcsDiffResponses = { export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] +export type VcsStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/status" +} + +export type VcsStatusResponses = { + /** + * VCS status + */ + 200: Array +} + +export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses] + +export type VcsDiffRawData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/diff/raw" +} + +export type VcsDiffRawErrors = { + /** + * Raw VCS diff failure + */ + 413: VcsDiffRawFailure +} + +export type VcsDiffRawError = VcsDiffRawErrors[keyof VcsDiffRawErrors] + +export type VcsDiffRawResponses = { + /** + * Raw VCS diff + */ + 200: string +} + +export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses] + +export type VcsApplyData = { + body?: { + patch: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/apply" +} + +export type VcsApplyErrors = { + /** + * VCS patch apply failure + */ + 400: VcsApplyFailure + /** + * VCS patch apply failure + */ + 413: VcsApplyFailure +} + +export type VcsApplyError = VcsApplyErrors[keyof VcsApplyErrors] + +export type VcsApplyResponses = { + /** + * Patch apply result + */ + 200: { + applied: boolean + } +} + +export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] + export type CommandListData = { body?: never path?: never