import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" import { zod, zodObject } from "@opencode-ai/core/effect-zod" import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 const MAX_PATCH_BYTES = 10_000_000 const MAX_TOTAL_PATCH_BYTES = 10_000_000 const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) const nums = (list: Git.Stat[]) => new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) const merge = (...lists: Git.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: Git.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* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) { if (list.length === 0) return { patches: new Map(), capped: false } const result = yield* git.patchAll(cwd, ref, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_TOTAL_PATCH_BYTES, }) if (result.truncated) log.warn("batched patch exceeded byte limit", { max: 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* ( git: Git.Interface, cwd: string, ref: string | undefined, item: Git.Item, ) { const result = item.code === "??" || !ref ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) if (!result.truncated && result.text) return result.text if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES }) 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 } log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES }) return { patch: emptyPatch(file), capped: true } } const patchForItem = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, ref: string | undefined, item: Git.Item, batch: { patches: Map; capped: boolean }, capped: boolean, ) { 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(git, cwd, ref, item) }) const files = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, ref: string | undefined, list: Git.Item[], map: Map, batch: { patches: Map; capped: boolean }, ) { const next: FileDiff[] = [] 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* git.statUntracked(cwd, item.file) : undefined) const patch = yield* patchForItem(git, cwd, ref, item, batch, capped) 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* (git: Git.Interface, cwd: string, ref: string) { const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { concurrency: 3, }) return yield* files( git, cwd, ref, merge( list, extra.filter((item) => item.code === "??"), ), nums(stats), yield* batchPatches(git, cwd, ref, list), ) }) const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch()) return yield* diffAgainstRef(git, cwd, ref) }) export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type Mode = Schema.Schema.Type export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", Schema.Struct({ branch: Schema.optional(Schema.String), }), ), } export const Info = Schema.Struct({ branch: Schema.optional(Schema.String), default_branch: Schema.optional(Schema.String), }) .annotate({ identifier: "VcsInfo" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, patch: Schema.String, additions: NonNegativeInt, deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ file: Schema.String, additions: NonNegativeInt, deletions: NonNegativeInt, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "VcsFileStatus" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileStatus = Schema.Schema.Type export const ApplyInput = Schema.Struct({ patch: Schema.String, }).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) }))) export type ApplyInput = Schema.Schema.Type export const ApplyResult = Schema.Struct({ applied: Schema.Boolean, }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ApplyResult = Schema.Schema.Type export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { message: Schema.String, reason: Schema.Literals(["non-git", "not-clean"]), }) {} 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 { current: string | undefined root: Git.Base | undefined } export class Service extends Context.Service()("@opencode/Vcs") {} export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const git = yield* Git.Service const bus = yield* Bus.Service const scope = yield* Scope.Scope const state = yield* InstanceState.make( Effect.fn("Vcs.state")(function* (ctx) { if (ctx.project.vcs !== "git") { return { current: undefined, root: undefined } } const get = Effect.fnUntraced(function* () { return yield* git.branch(ctx.directory) }) const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { concurrency: 2, }) const value = { current, root } log.info("initialized", { branch: value.current, default_branch: value.root?.name }) yield* bus.subscribe(FileWatcher.Event.Updated).pipe( Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), Stream.runForEach((_evt) => Effect.gen(function* () { const next = yield* get() if (next !== value.current) { log.info("branch changed", { from: value.current, to: next }) value.current = next yield* bus.publish(Event.BranchUpdated, { branch: next }) } }), ), Effect.forkScoped, ) return value }), ) return Service.of({ init: Effect.fn("Vcs.init")(function* () { yield* InstanceState.get(state).pipe(Effect.forkIn(scope)) }), branch: Effect.fn("Vcs.branch")(function* () { return yield* InstanceState.use(state, (x) => x.current) }), defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { return yield* InstanceState.use(state, (x) => x.root?.name) }), status: Effect.fn("Vcs.status")(function* () { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") return [] 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([])], { 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("Vcs.diff")(function* (mode: Mode) { const value = yield* InstanceState.get(state) const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") return [] if (mode === "git") { return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined) } if (!value.root) return [] if (value.current && value.current === value.root.name) return [] const ref = yield* git.mergeBase(ctx.directory, value.root.ref) if (!ref) return [] return yield* diffAgainstRef(git, ctx.directory, ref) }), diffRaw: Effect.fn("Vcs.diffRaw")(function* () { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") return "" const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], { concurrency: 2, }) const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : "" const untracked = yield* Effect.forEach( status.filter((item) => item.code === "??"), (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)), ) return [tracked, ...untracked].filter(Boolean).join("\n") }), apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { return yield* new PatchApplyError({ message: "Patch can't be applied because the project is not git-based", reason: "non-git", }) } const applied = yield* git.applyPatch(ctx.directory, input.patch) if (applied.exitCode !== 0) { return yield* new PatchApplyError({ message: "Patch can't be applied", reason: "not-clean", }) } return { applied: true } }), }) }), ) export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer)) export * as Vcs from "./vcs"