411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
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<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"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
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" })
|
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
export type Info = Schema.Schema.Type<typeof Info>
|
|
|
|
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<typeof FileDiff>
|
|
|
|
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<typeof FileStatus>
|
|
|
|
export const ApplyInput = Schema.Struct({
|
|
patch: Schema.String,
|
|
}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) })))
|
|
export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
|
|
|
|
export const ApplyResult = Schema.Struct({
|
|
applied: Schema.Boolean,
|
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
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"
|