diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 77923e5a6c..f25557c0de 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1012,6 +1012,24 @@ const adaptGroup23 = (raw: RawClient["server.projectCopy"]) => ({ refresh: Endpoint23_2(raw), }) +type Endpoint24_0Request = Parameters[0] +type Endpoint24_0Input = { readonly location?: Endpoint24_0Request["query"]["location"] } +const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) => + raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint24_1Request = Parameters[0] +type Endpoint24_1Input = { + readonly location?: Endpoint24_1Request["query"]["location"] + readonly mode: Endpoint24_1Request["query"]["mode"] + readonly context?: Endpoint24_1Request["query"]["context"] +} +const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input) => + raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) }) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), @@ -1037,6 +1055,7 @@ const adaptClient = (raw: RawClient) => ({ question: adaptGroup21(raw["server.question"]), reference: adaptGroup22(raw["server.reference"]), projectCopy: adaptGroup23(raw["server.projectCopy"]), + vcs: adaptGroup24(raw["server.vcs"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 46522256bc..ee752f23ef 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -167,6 +167,10 @@ import type { ProjectCopyRemoveOutput, ProjectCopyRefreshInput, ProjectCopyRefreshOutput, + VcsStatusInput, + VcsStatusOutput, + VcsDiffInput, + VcsDiffOutput, } from "./types" import { ClientError } from "./client-error" @@ -1404,6 +1408,32 @@ export function make(options: ClientOptions) { requestOptions, ), }, + vcs: { + status: (input?: VcsStatusInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/vcs/status`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + diff: (input: VcsDiffInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/vcs/diff`, + query: { location: input["location"], mode: input["mode"], context: input["context"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a0b9d6fadd..d858ded933 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -5924,3 +5924,56 @@ export type ProjectCopyRefreshInput = { } export type ProjectCopyRefreshOutput = void + +export type VcsStatusInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type VcsStatusOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly file: string + readonly additions: number + readonly deletions: number + readonly status: "added" | "deleted" | "modified" + }> +} + +export type VcsDiffInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly mode: "working" | "branch" + readonly context?: number | undefined + }["location"] + readonly mode: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly mode: "working" | "branch" + readonly context?: number | undefined + }["mode"] + readonly context?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly mode: "working" | "branch" + readonly context?: number | undefined + }["context"] +} + +export type VcsDiffOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly file?: string + readonly patch?: string + readonly additions: number + readonly deletions: number + readonly status?: "added" | "deleted" | "modified" + }> +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 03a81a1dc9..030b761fa6 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -29,6 +29,7 @@ test("exposes every standard HTTP API group", () => { "question", "reference", "projectCopy", + "vcs", ]) expect(Object.keys(client.message)).toEqual(["list"]) expect(Object.keys(client.integration)).toEqual([ @@ -41,6 +42,7 @@ test("exposes every standard HTTP API group", () => { "attemptCancel", ]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) + expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) expect(Object.keys(client.project)).toEqual(["current", "directories"]) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index d3150668fe..830b9b48be 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -46,6 +46,7 @@ import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" import { ToolOutputStore } from "./tool-output-store" +import { Vcs } from "./vcs" export { LocationServiceMap } from "./location-service-map" @@ -96,6 +97,7 @@ export const locationServices = LayerNode.group([ SessionTitle.node, Snapshot.node, SessionRunnerLLM.node, + Vcs.node, ]) export type LocationServices = LayerNode.Output diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts new file mode 100644 index 0000000000..1a108db0b1 --- /dev/null +++ b/packages/core/src/vcs.ts @@ -0,0 +1,51 @@ +export * as Vcs from "./vcs" + +import { Context, Effect, Layer } from "effect" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { FileStatus, Mode } from "@opencode-ai/schema/vcs" +import { makeLocationNode } from "./effect/app-node" +import { Location } from "./location" +import { AppProcess } from "./process" +import { VcsGit } from "./vcs/git" + +export { FileStatus, Mode } + +export interface DiffOptions { + readonly context?: number +} + +export interface Interface { + readonly status: () => Effect.Effect + readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Vcs") {} + +// Adapter seam: one working-copy implementation per VCS type, selected by the +// resolved location. Locations without a supported VCS degrade to empty +// results so callers never need to special-case. +const adapter = (proc: AppProcess.Interface, location: Location.Interface) => { + if (location.vcs?.type === "git") + return VcsGit.make(proc, { directory: location.directory, worktree: location.project.directory }) +} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const proc = yield* AppProcess.Service + const location = yield* Location.Service + const impl = adapter(proc, location) + return Service.of({ + status: Effect.fn("Vcs.status")(function* () { + if (!impl) return [] + return yield* impl.status() + }), + diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) { + if (!impl) return [] + return yield* impl.diff(mode, options) + }), + }) + }), +) + +export const node = makeLocationNode({ service: Service, layer: layer, deps: [AppProcess.node, Location.node] }) diff --git a/packages/core/src/vcs/git.ts b/packages/core/src/vcs/git.ts new file mode 100644 index 0000000000..5da546b39b --- /dev/null +++ b/packages/core/src/vcs/git.ts @@ -0,0 +1,521 @@ +export * as VcsGit from "./git" + +import { formatPatch, structuredPatch } from "diff" +import { Effect } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { FileStatus, Mode } from "@opencode-ai/schema/vcs" +import { AppProcess } from "../process" +import type { DiffOptions, Interface } from "../vcs" + +const PATCH_CONTEXT_LINES = 2_147_483_647 +const MAX_PATCH_BYTES = 10_000_000 +const MAX_TOTAL_PATCH_BYTES = 10_000_000 + +/** + * Git adapter for the Vcs service. Ported from the V1 pipeline: patches are + * batched through one `git diff` invocation where possible and capped by + * per-file and total byte budgets, falling back to empty patches when capped. + */ +export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Interface { + // Listing commands scope pathspecs to the requested directory; per-file + // commands run from the worktree root because git lists root-relative paths. + const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree } + + return { + status: Effect.fn("VcsGit.status")(function* () { + const git = ctx.git + const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined + const [list, stats] = yield* Effect.all( + [git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([] as Stat[])], + { concurrency: 2 }, + ) + const map = nums(stats) + return yield* Effect.forEach( + list.toSorted((a, b) => a.file.localeCompare(b.file)), + (item) => + Effect.gen(function* () { + const stat = + map.get(item.file) ?? + (item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined) + return { + file: item.file, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + } satisfies FileStatus + }), + ) + }), + diff: Effect.fn("VcsGit.diff")(function* (mode: Mode, options?: DiffOptions) { + const git = ctx.git + if (mode === "working") { + return yield* track(ctx, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options) + } + + const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { + concurrency: 2, + }) + if (!root) return [] + if (current && current === root.name) return [] + const ref = yield* git.mergeBase(ctx.directory, root.ref) + if (!ref) return [] + return yield* diffAgainstRef(ctx, ref, options) + }), + } +} + +type Kind = FileStatus["status"] + +interface Base { + readonly name: string + readonly ref: string +} + +interface Item { + readonly file: string + readonly code: string + readonly status: Kind +} + +interface Stat { + readonly file: string + readonly additions: number + readonly deletions: number +} + +interface Patch { + readonly text: string + readonly truncated: boolean +} + +interface PatchOptions { + readonly context?: number + readonly maxOutputBytes?: number +} + +interface Ctx { + readonly git: GitOps + readonly directory: string + readonly worktree: string +} + +type GitOps = ReturnType + +const cfg = [ + "--no-optional-locks", + "-c", + "core.autocrlf=false", + "-c", + "core.fsmonitor=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", +] as const + +const kind = (code: string): Kind => { + if (code === "??") return "added" + if (code.includes("U")) return "modified" + if (code.includes("A") && !code.includes("D")) return "added" + if (code.includes("D") && !code.includes("A")) return "deleted" + return "modified" +} + +const nuls = (text: string) => text.split("\0").filter(Boolean) + +function makeGit(proc: AppProcess.Interface) { + const run = Effect.fnUntraced( + function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) { + const result = yield* proc.run( + ChildProcess.make("git", [...cfg, ...args], { + cwd: opts.cwd, + extendEnv: true, + stdin: "ignore", + }), + { maxOutputBytes: opts.maxOutputBytes }, + ) + return { + exitCode: result.exitCode, + text: () => result.stdout.toString("utf8"), + truncated: result.stdoutTruncated || result.stderrTruncated, + } + }, + Effect.catch(() => Effect.succeed({ exitCode: 1, text: () => "", truncated: false })), + ) + + const text = Effect.fnUntraced(function* (args: string[], opts: { cwd: string }) { + return (yield* run(args, opts)).text() + }) + + const lines = Effect.fnUntraced(function* (args: string[], opts: { cwd: string }) { + return (yield* text(args, opts)) + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean) + }) + + const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { + const result = yield* run(["config", "init.defaultBranch"], { cwd }) + const name = result.text().trim() + if (!name || !list.includes(name)) return + return { name, ref: name } satisfies Base + }) + + const primary = Effect.fnUntraced(function* (cwd: string) { + const list = yield* lines(["remote"], { cwd }) + if (list.includes("origin")) return "origin" + if (list.length === 1) return list[0] + if (list.includes("upstream")) return "upstream" + return list[0] + }) + + const branch = Effect.fn("VcsGit.branch")(function* (cwd: string) { + const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd }) + if (result.exitCode !== 0) return + return result.text().trim() || undefined + }) + + const defaultBranch = Effect.fn("VcsGit.defaultBranch")(function* (cwd: string) { + const remote = yield* primary(cwd) + if (remote) { + const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) + if (head.exitCode === 0) { + const ref = head.text().trim().replace(/^refs\/remotes\//, "") + const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" + if (name) return { name, ref } satisfies Base + } + } + + const list = yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) + const next = yield* configured(cwd, list) + if (next) return next + if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base + if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base + }) + + const hasHead = Effect.fn("VcsGit.hasHead")(function* (cwd: string) { + const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) + return result.exitCode === 0 + }) + + const mergeBase = Effect.fn("VcsGit.mergeBase")(function* (cwd: string, base: string) { + const result = yield* run(["merge-base", base, "HEAD"], { cwd }) + if (result.exitCode !== 0) return + return result.text().trim() || undefined + }) + + const status = Effect.fn("VcsGit.statusNames")(function* (cwd: string) { + return nuls( + yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { cwd }), + ).flatMap((item) => { + const file = item.slice(3) + if (!file) return [] + const code = item.slice(0, 2) + return [{ file, code, status: kind(code) } satisfies Item] + }) + }) + + const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string) { + const list = nuls( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), + ) + return list.flatMap((code, idx) => { + if (idx % 2 !== 0) return [] + const file = list[idx + 1] + if (!code || !file) return [] + return [{ file, code, status: kind(code) } satisfies Item] + }) + }) + + const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string) { + return nuls( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), + ).flatMap((item) => { + const a = item.indexOf("\t") + const b = item.indexOf("\t", a + 1) + if (a === -1 || b === -1) return [] + const file = item.slice(b + 1) + if (!file) return [] + const adds = item.slice(0, a) + const dels = item.slice(a + 1, b) + const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) + const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) + return [ + { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat, + ] + }) + }) + + const patch = Effect.fn("VcsGit.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchAll = Effect.fn("VcsGit.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchUntracked = Effect.fn("VcsGit.patchUntracked")(function* ( + cwd: string, + file: string, + options?: PatchOptions, + ) { + const result = yield* run( + [ + "diff", + "--no-index", + "--patch", + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const statUntracked = Effect.fn("VcsGit.statUntracked")(function* (cwd: string, file: string) { + const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { + cwd, + maxOutputBytes: 4096, + }) + if (result.truncated) return + + const parts = result.text().split("\t") + if (parts.length < 2) return + + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) + return { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat + }) + + return { + branch, + defaultBranch, + hasHead, + mergeBase, + status, + diff, + stats, + patch, + patchAll, + patchUntracked, + statUntracked, + } +} + +const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) + +const nums = (list: Stat[]) => + new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) + +const merge = (...lists: Item[][]) => { + const out = new Map() + lists.flat().forEach((item) => { + if (!out.has(item.file)) out.set(item.file, item) + }) + return [...out.values()] +} + +const emptyBatch = () => ({ patches: new Map(), capped: false }) + +const parseQuotedPath = (value: string) => { + let out = "" + for (let idx = 1; idx < value.length; idx++) { + const char = value[idx] + if (char === '"') return { value: out, end: idx + 1 } + if (char !== "\\") { + out += char + continue + } + + const next = value[++idx] + if (next === "t") out += "\t" + else if (next === "n") out += "\n" + else if (next === "r") out += "\r" + else if (next === '"' || next === "\\") out += next + else out += next ?? "" + } +} + +const parsePathToken = (value: string) => { + if (!value.startsWith('"')) return value.split("\t")[0] + return parseQuotedPath(value)?.value ?? value +} + +const fileFromDiffPath = (value: string | undefined) => { + if (!value || value === "/dev/null") return + const file = parsePathToken(value) + if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2) + return file +} + +const fileFromGitHeader = (header: string) => { + if (header.startsWith('"')) { + const first = parseQuotedPath(header) + const second = first ? header.slice(first.end).trimStart() : undefined + if (!second) return + if (!second.startsWith('"')) return fileFromDiffPath(second) + return fileFromDiffPath(parseQuotedPath(second)?.value) + } + + const separator = header.indexOf(" b/") + if (separator === -1) return + return fileFromDiffPath(header.slice(separator + 1)) +} + +const fileFromPatchChunk = (chunk: string) => { + const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1] + const before = /^--- (.+)$/m.exec(chunk)?.[1] + const file = fileFromDiffPath(next) ?? fileFromDiffPath(before) + if (file) return file + + const header = /^diff --git (.+)$/m.exec(chunk)?.[1] + return fileFromGitHeader(header ?? "") +} + +const splitGitPatch = (patch: Patch) => { + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) + const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) + if (!patch.truncated) return chunks + return chunks.slice(0, -1) +} + +const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: DiffOptions) { + if (list.length === 0) return emptyBatch() + + const result = yield* ctx.git.patchAll(ctx.directory, ref, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + + return { + patches: splitGitPatch(result).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? list[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.truncated, + } +}) + +const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, item: Item, options?: DiffOptions) { + const result = + item.code === "??" || !ref + ? yield* ctx.git.patchUntracked(ctx.worktree, item.file, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_PATCH_BYTES, + }) + : yield* ctx.git.patch(ctx.worktree, ref, item.file, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_PATCH_BYTES, + }) + if (!result.truncated && result.text) return result.text + + return emptyPatch(item.file) +}) + +const totalPatch = (file: string, patch: string, total: number) => { + if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } + return { patch: emptyPatch(file), capped: true } +} + +const patchForItem = Effect.fnUntraced(function* ( + ctx: Ctx, + ref: string | undefined, + item: Item, + batch: { patches: Map; capped: boolean }, + capped: boolean, + options?: DiffOptions, +) { + if (capped) return emptyPatch(item.file) + + const batched = batch.patches.get(item.file) + if (batched !== undefined) return batched + if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + return yield* nativePatch(ctx, ref, item, options) +}) + +const files = Effect.fnUntraced(function* ( + ctx: Ctx, + ref: string | undefined, + list: Item[], + map: Map, + batch: { patches: Map; capped: boolean }, + options?: DiffOptions, +) { + const next: FileDiff.Info[] = [] + let total = 0 + let capped = false + + for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { + const stat = + map.get(item.file) ?? + (item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined) + const patch = yield* patchForItem(ctx, ref, item, batch, capped, options) + const result: { patch: string; capped: boolean } = capped + ? { patch, capped: true } + : totalPatch(item.file, patch, total) + capped = capped || result.capped + if (!capped) { + total += Buffer.byteLength(result.patch) + capped = total >= MAX_TOTAL_PATCH_BYTES + } + next.push({ + file: item.file, + patch: result.patch, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + }) + } + + return next +}) + +const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: DiffOptions) { + const [list, stats, extra] = yield* Effect.all( + [ctx.git.diff(ctx.directory, ref), ctx.git.stats(ctx.directory, ref), ctx.git.status(ctx.directory)], + { concurrency: 3 }, + ) + return yield* files( + ctx, + ref, + merge( + list, + extra.filter((item) => item.code === "??"), + ), + nums(stats), + yield* batchPatches(ctx, ref, list, options), + options, + ) +}) + +const track = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, options?: DiffOptions) { + if (!ref) return yield* files(ctx, ref, yield* ctx.git.status(ctx.directory), new Map(), emptyBatch(), options) + return yield* diffAgainstRef(ctx, ref, options) +}) diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts new file mode 100644 index 0000000000..2a42d1f54a --- /dev/null +++ b/packages/core/test/vcs.test.ts @@ -0,0 +1,173 @@ +import { $ } from "bun" +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Vcs } from "@opencode-ai/core/vcs" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { it } from "./lib/effect" + +const provide = (directory: string, input: { git?: boolean } = {}) => + Effect.provide( + LayerNode.compile(Vcs.node, [ + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {}, + ), + ), + ), + ], + ]), + ) + +const withTmp = (f: (directory: string) => Effect.Effect) => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => f(tmp.path))) + +async function initRepo(directory: string) { + await $`git init -b main`.cwd(directory).quiet() + await $`git config core.fsmonitor false`.cwd(directory).quiet() + await $`git config commit.gpgsign false`.cwd(directory).quiet() + await $`git config user.email test@opencode.test`.cwd(directory).quiet() + await $`git config user.name Test`.cwd(directory).quiet() +} + +async function commitAll(directory: string, message: string) { + await $`git add -A`.cwd(directory).quiet() + await $`git commit -m ${message}`.cwd(directory).quiet() +} + +describe("Vcs", () => { + it.live("returns empty results outside version control", () => + withTmp((directory) => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + expect(yield* vcs.status()).toEqual([]) + expect(yield* vcs.diff("working")).toEqual([]) + expect(yield* vcs.diff("branch")).toEqual([]) + }).pipe(provide(directory)), + ), + ) + + it.live("reports modified, deleted, and untracked files", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await initRepo(directory) + await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n") + await fs.writeFile(path.join(directory, "gone.txt"), "bye\n") + await commitAll(directory, "initial") + await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n") + await fs.rm(path.join(directory, "gone.txt")) + await fs.writeFile(path.join(directory, "new.txt"), "hello\nworld\n") + }) + const vcs = yield* Vcs.Service + const status = yield* vcs.status() + expect(status).toEqual([ + { file: "gone.txt", additions: 0, deletions: 1, status: "deleted" }, + { file: "keep.txt", additions: 1, deletions: 1, status: "modified" }, + { file: "new.txt", additions: 2, deletions: 0, status: "added" }, + ]) + }).pipe(provide(directory, { git: true })), + ), + ) + + it.live("diffs the working copy against HEAD with patches", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await initRepo(directory) + await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n") + await commitAll(directory, "initial") + await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n") + await fs.writeFile(path.join(directory, "spaced name.txt"), "hello\n") + }) + const vcs = yield* Vcs.Service + const diff = yield* vcs.diff("working") + expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([ + { file: "keep.txt", status: "modified" }, + { file: "spaced name.txt", status: "added" }, + ]) + expect(diff[0].patch).toContain("-two") + expect(diff[0].patch).toContain("+three") + expect(diff[0].additions).toBe(1) + expect(diff[0].deletions).toBe(1) + expect(diff[1].patch).toContain("+hello") + expect(diff[1].additions).toBe(1) + }).pipe(provide(directory, { git: true })), + ), + ) + + it.live("respects the context option", () => + withTmp((directory) => + Effect.gen(function* () { + const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n" + yield* Effect.promise(async () => { + await initRepo(directory) + await fs.writeFile(path.join(directory, "file.txt"), body) + await commitAll(directory, "initial") + await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed")) + }) + const vcs = yield* Vcs.Service + const full = yield* vcs.diff("working") + expect(full[0].patch).toContain("line-0") + expect(full[0].patch).toContain("line-19") + const tight = yield* vcs.diff("working", { context: 1 }) + expect(tight[0].patch).toContain("line-9") + expect(tight[0].patch).not.toContain("line-0") + }).pipe(provide(directory, { git: true })), + ), + ) + + it.live("diffs before the first commit", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await initRepo(directory) + await fs.writeFile(path.join(directory, "new.txt"), "hello\n") + }) + const vcs = yield* Vcs.Service + expect(yield* vcs.status()).toEqual([{ file: "new.txt", additions: 1, deletions: 0, status: "added" }]) + const diff = yield* vcs.diff("working") + expect(diff).toHaveLength(1) + expect(diff[0].patch).toContain("+hello") + }).pipe(provide(directory, { git: true })), + ), + ) + + it.live("diffs a feature branch against the default branch", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await initRepo(directory) + await fs.writeFile(path.join(directory, "file.txt"), "one\n") + await commitAll(directory, "initial") + }) + const vcs = yield* Vcs.Service + expect(yield* vcs.diff("branch")).toEqual([]) + + yield* Effect.promise(async () => { + await $`git checkout -q -b feature`.cwd(directory).quiet() + await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n") + await commitAll(directory, "feature change") + }) + const diff = yield* vcs.diff("branch") + expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([ + { file: "file.txt", status: "modified" }, + ]) + expect(diff[0].patch).toContain("+two") + }).pipe(provide(directory, { git: true })), + ), + ) +}) diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index f027e0c436..049ca7486c 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -833,6 +833,25 @@ export interface ProjectCopyApi { readonly refresh: ProjectCopyRefreshOperation } +type Endpoint24_0Request = Parameters[0] +export type Endpoint24_0Input = { readonly location?: Endpoint24_0Request["query"]["location"] } +export type Endpoint24_0Output = EffectValue> +export type VcsStatusOperation = (input?: Endpoint24_0Input) => Effect.Effect + +type Endpoint24_1Request = Parameters[0] +export type Endpoint24_1Input = { + readonly location?: Endpoint24_1Request["query"]["location"] + readonly mode: Endpoint24_1Request["query"]["mode"] + readonly context?: Endpoint24_1Request["query"]["context"] +} +export type Endpoint24_1Output = EffectValue> +export type VcsDiffOperation = (input: Endpoint24_1Input) => Effect.Effect + +export interface VcsApi { + readonly status: VcsStatusOperation + readonly diff: VcsDiffOperation +} + export interface AppApi { readonly health: HealthApi readonly location: LocationApi @@ -858,4 +877,5 @@ export interface AppApi { readonly question: QuestionApi readonly reference: ReferenceApi readonly projectCopy: ProjectCopyApi + readonly vcs: VcsApi } diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index e1d29d8a05..04d60888d7 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -27,6 +27,7 @@ import { McpGroup } from "./groups/mcp.js" import { CredentialGroup } from "./groups/credential.js" import { ProjectGroup } from "./groups/project.js" import { ProjectCopyGroup } from "./groups/project-copy.js" +import { VcsGroup } from "./groups/vcs.js" type LocationGroups = | HttpApiGroup.AddMiddleware @@ -46,6 +47,7 @@ type LocationGroups = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware type SessionGroups = | ReturnType> @@ -162,6 +164,7 @@ const makeApiFromGroup = < .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) .add(ReferenceGroup.middleware(locationMiddleware)) .add(ProjectCopyGroup.middleware(locationMiddleware)) + .add(VcsGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 138fd68acf..3976289ed9 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -56,6 +56,7 @@ export const groupNames = { "server.reference": "reference", "server.project": "project", "server.projectCopy": "projectCopy", + "server.vcs": "vcs", } as const export const endpointNames = { diff --git a/packages/protocol/src/groups/vcs.ts b/packages/protocol/src/groups/vcs.ts new file mode 100644 index 0000000000..5c2b85ceb2 --- /dev/null +++ b/packages/protocol/src/groups/vcs.ts @@ -0,0 +1,50 @@ +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { Location } from "@opencode-ai/schema/location" +import { NonNegativeInt } from "@opencode-ai/schema/schema" +import { Vcs } from "@opencode-ai/schema/vcs" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location.js" + +const DiffQuery = Schema.Struct({ + ...LocationQuery.fields, + mode: Vcs.Mode, + context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), +}) + +export const VcsGroup = HttpApiGroup.make("server.vcs") + .add( + HttpApiEndpoint.get("vcs.status", "/api/vcs/status", { + query: LocationQuery, + success: Location.response(Schema.Array(Vcs.FileStatus)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.vcs.status", + summary: "VCS status", + description: "List uncommitted working-copy changes relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("vcs.diff", "/api/vcs/diff", { + query: DiffQuery, + success: Location.response(Schema.Array(FileDiff.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.vcs.diff", + summary: "VCS diff", + description: + "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "vcs", + description: "Location-scoped version control routes.", + }), + ) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 9714fdc762..fb2ef17b96 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -17,6 +17,7 @@ export { Provider } from "./provider.js" export { Reference } from "./reference.js" export { Revert } from "./revert.js" export { Session } from "./session.js" +export { Vcs } from "./vcs.js" export { SessionInput } from "./session-input.js" export { SessionMessage } from "./session-message.js" export { Shell } from "./shell.js" diff --git a/packages/schema/src/vcs.ts b/packages/schema/src/vcs.ts new file mode 100644 index 0000000000..08127d8b51 --- /dev/null +++ b/packages/schema/src/vcs.ts @@ -0,0 +1,15 @@ +export * as Vcs from "./vcs.js" + +import { Schema } from "effect" +import { NonNegativeInt } from "./schema.js" + +export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" }) +export type Mode = typeof Mode.Type + +export const FileStatus = Schema.Struct({ + file: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, + status: Schema.Literals(["added", "deleted", "modified"]), +}).annotate({ identifier: "Vcs.FileStatus" }) +export interface FileStatus extends Schema.Schema.Type {} diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 5a37247e99..bfae813372 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -23,6 +23,7 @@ import { McpHandler } from "./handlers/mcp" import { CredentialHandler } from "./handlers/credential" import { ProjectHandler } from "./handlers/project" import { ProjectCopyHandler } from "./handlers/project-copy" +import { VcsHandler } from "./handlers/vcs" export const handlers = Layer.mergeAll( HealthHandler, @@ -49,4 +50,5 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, + VcsHandler, ) diff --git a/packages/server/src/handlers/vcs.ts b/packages/server/src/handlers/vcs.ts new file mode 100644 index 0000000000..325eed474a --- /dev/null +++ b/packages/server/src/handlers/vcs.ts @@ -0,0 +1,27 @@ +import { Vcs } from "@opencode-ai/core/vcs" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) => + Effect.gen(function* () { + return handlers + .handle("vcs.status", () => + response( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.status() + }), + ), + ) + .handle("vcs.diff", (ctx) => + response( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff(ctx.query.mode, { context: ctx.query.context }) + }), + ), + ) + }), +)