| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 |
- 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"
- 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<string, Git.Item>()
- lists.flat().forEach((item) => {
- if (!out.has(item.file)) out.set(item.file, item)
- })
- return [...out.values()]
- }
- const emptyBatch = () => ({ patches: new Map<string, string>(), 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<string, string>(), 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<string, string>()),
- 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<string, string>; 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<string, { additions: number; deletions: number }>,
- batch: { patches: Map<string, string>; 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"])
- export type Mode = Schema.Schema.Type<typeof Mode>
- 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" })
- export type Info = Schema.Schema.Type<typeof Info>
- export const FileDiff = Schema.Struct({
- file: Schema.String,
- // Mirrors Snapshot.FileDiff (see #26574). The current producer always
- // populates patch, but loosening matches the sibling schema so a
- // future code path that omits it can't crash /instance/vcs/diff.
- patch: Schema.optional(Schema.String),
- additions: Schema.Finite,
- deletions: Schema.Finite,
- status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
- }).annotate({ identifier: "VcsFileDiff" })
- export type FileDiff = Schema.Schema.Type<typeof FileDiff>
- export const FileStatus = Schema.Struct({
- file: Schema.String,
- additions: Schema.Finite,
- deletions: Schema.Finite,
- status: Schema.Literals(["added", "deleted", "modified"]),
- }).annotate({ identifier: "VcsFileStatus" })
- export type FileStatus = Schema.Schema.Type<typeof FileStatus>
- export const ApplyInput = Schema.Struct({
- patch: Schema.String,
- })
- export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
- export const ApplyResult = Schema.Struct({
- applied: Schema.Boolean,
- })
- export type ApplyResult = Schema.Schema.Type<typeof ApplyResult>
- export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()("VcsPatchApplyError", {
- message: Schema.String,
- reason: Schema.Literals(["non-git", "not-clean"]),
- }) {}
- export interface Interface {
- readonly init: () => Effect.Effect<void>
- readonly branch: () => Effect.Effect<string | undefined>
- readonly defaultBranch: () => Effect.Effect<string | undefined>
- readonly status: () => Effect.Effect<FileStatus[]>
- readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
- readonly diffRaw: () => Effect.Effect<string>
- readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
- }
- interface State {
- current: string | undefined
- root: Git.Base | undefined
- }
- export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
- export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = 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<State>(
- 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"
|