vcs.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
  2. import { formatPatch, structuredPatch } from "diff"
  3. import { Bus } from "@/bus"
  4. import { BusEvent } from "@/bus/bus-event"
  5. import { InstanceState } from "@/effect/instance-state"
  6. import { FileWatcher } from "@/file/watcher"
  7. import { Git } from "@/git"
  8. import * as Log from "@opencode-ai/core/util/log"
  9. const log = Log.create({ service: "vcs" })
  10. const PATCH_CONTEXT_LINES = 2_147_483_647
  11. const MAX_PATCH_BYTES = 10_000_000
  12. const MAX_TOTAL_PATCH_BYTES = 10_000_000
  13. const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
  14. const nums = (list: Git.Stat[]) =>
  15. new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
  16. const merge = (...lists: Git.Item[][]) => {
  17. const out = new Map<string, Git.Item>()
  18. lists.flat().forEach((item) => {
  19. if (!out.has(item.file)) out.set(item.file, item)
  20. })
  21. return [...out.values()]
  22. }
  23. const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
  24. const parseQuotedPath = (value: string) => {
  25. let out = ""
  26. for (let idx = 1; idx < value.length; idx++) {
  27. const char = value[idx]
  28. if (char === '"') return { value: out, end: idx + 1 }
  29. if (char !== "\\") {
  30. out += char
  31. continue
  32. }
  33. const next = value[++idx]
  34. if (next === "t") out += "\t"
  35. else if (next === "n") out += "\n"
  36. else if (next === "r") out += "\r"
  37. else if (next === '"' || next === "\\") out += next
  38. else out += next ?? ""
  39. }
  40. }
  41. const parsePathToken = (value: string) => {
  42. if (!value.startsWith('"')) return value.split("\t")[0]
  43. return parseQuotedPath(value)?.value ?? value
  44. }
  45. const fileFromDiffPath = (value: string | undefined) => {
  46. if (!value || value === "/dev/null") return
  47. const file = parsePathToken(value)
  48. if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
  49. return file
  50. }
  51. const fileFromGitHeader = (header: string) => {
  52. if (header.startsWith('"')) {
  53. const first = parseQuotedPath(header)
  54. const second = first ? header.slice(first.end).trimStart() : undefined
  55. if (!second) return
  56. if (!second.startsWith('"')) return fileFromDiffPath(second)
  57. return fileFromDiffPath(parseQuotedPath(second)?.value)
  58. }
  59. const separator = header.indexOf(" b/")
  60. if (separator === -1) return
  61. return fileFromDiffPath(header.slice(separator + 1))
  62. }
  63. const fileFromPatchChunk = (chunk: string) => {
  64. const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
  65. const before = /^--- (.+)$/m.exec(chunk)?.[1]
  66. const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
  67. if (file) return file
  68. const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
  69. return fileFromGitHeader(header ?? "")
  70. }
  71. const splitGitPatch = (patch: Git.Patch) => {
  72. const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
  73. match[0].startsWith("\n") ? match.index + 1 : match.index,
  74. )
  75. const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
  76. if (!patch.truncated) return chunks
  77. return chunks.slice(0, -1)
  78. }
  79. const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
  80. if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
  81. const result = yield* git.patchAll(cwd, ref, {
  82. context: PATCH_CONTEXT_LINES,
  83. maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
  84. })
  85. if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
  86. return {
  87. patches: splitGitPatch(result).reduce((acc, patch, index) => {
  88. const file = fileFromPatchChunk(patch) ?? list[index]?.file
  89. if (!file) return acc
  90. acc.set(file, (acc.get(file) ?? "") + patch)
  91. return acc
  92. }, new Map<string, string>()),
  93. capped: result.truncated,
  94. }
  95. })
  96. const nativePatch = Effect.fnUntraced(function* (
  97. git: Git.Interface,
  98. cwd: string,
  99. ref: string | undefined,
  100. item: Git.Item,
  101. ) {
  102. const result =
  103. item.code === "??" || !ref
  104. ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
  105. : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
  106. if (!result.truncated && result.text) return result.text
  107. if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
  108. return emptyPatch(item.file)
  109. })
  110. const totalPatch = (file: string, patch: string, total: number) => {
  111. if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
  112. log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES })
  113. return { patch: emptyPatch(file), capped: true }
  114. }
  115. const patchForItem = Effect.fnUntraced(function* (
  116. git: Git.Interface,
  117. cwd: string,
  118. ref: string | undefined,
  119. item: Git.Item,
  120. batch: { patches: Map<string, string>; capped: boolean },
  121. capped: boolean,
  122. ) {
  123. if (capped) return emptyPatch(item.file)
  124. const batched = batch.patches.get(item.file)
  125. if (batched !== undefined) return batched
  126. if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
  127. return yield* nativePatch(git, cwd, ref, item)
  128. })
  129. const files = Effect.fnUntraced(function* (
  130. git: Git.Interface,
  131. cwd: string,
  132. ref: string | undefined,
  133. list: Git.Item[],
  134. map: Map<string, { additions: number; deletions: number }>,
  135. batch: { patches: Map<string, string>; capped: boolean },
  136. ) {
  137. const next: FileDiff[] = []
  138. let total = 0
  139. let capped = false
  140. for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
  141. const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
  142. const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
  143. const result: { patch: string; capped: boolean } = capped
  144. ? { patch, capped: true }
  145. : totalPatch(item.file, patch, total)
  146. capped = capped || result.capped
  147. if (!capped) {
  148. total += Buffer.byteLength(result.patch)
  149. capped = total >= MAX_TOTAL_PATCH_BYTES
  150. }
  151. next.push({
  152. file: item.file,
  153. patch: result.patch,
  154. additions: stat?.additions ?? 0,
  155. deletions: stat?.deletions ?? 0,
  156. status: item.status,
  157. })
  158. }
  159. return next
  160. })
  161. const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
  162. const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
  163. concurrency: 3,
  164. })
  165. return yield* files(
  166. git,
  167. cwd,
  168. ref,
  169. merge(
  170. list,
  171. extra.filter((item) => item.code === "??"),
  172. ),
  173. nums(stats),
  174. yield* batchPatches(git, cwd, ref, list),
  175. )
  176. })
  177. const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
  178. if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
  179. return yield* diffAgainstRef(git, cwd, ref)
  180. })
  181. export const Mode = Schema.Literals(["git", "branch"])
  182. export type Mode = Schema.Schema.Type<typeof Mode>
  183. export const Event = {
  184. BranchUpdated: BusEvent.define(
  185. "vcs.branch.updated",
  186. Schema.Struct({
  187. branch: Schema.optional(Schema.String),
  188. }),
  189. ),
  190. }
  191. export const Info = Schema.Struct({
  192. branch: Schema.optional(Schema.String),
  193. default_branch: Schema.optional(Schema.String),
  194. }).annotate({ identifier: "VcsInfo" })
  195. export type Info = Schema.Schema.Type<typeof Info>
  196. export const FileDiff = Schema.Struct({
  197. file: Schema.String,
  198. // Mirrors Snapshot.FileDiff (see #26574). The current producer always
  199. // populates patch, but loosening matches the sibling schema so a
  200. // future code path that omits it can't crash /instance/vcs/diff.
  201. patch: Schema.optional(Schema.String),
  202. additions: Schema.Finite,
  203. deletions: Schema.Finite,
  204. status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
  205. }).annotate({ identifier: "VcsFileDiff" })
  206. export type FileDiff = Schema.Schema.Type<typeof FileDiff>
  207. export const FileStatus = Schema.Struct({
  208. file: Schema.String,
  209. additions: Schema.Finite,
  210. deletions: Schema.Finite,
  211. status: Schema.Literals(["added", "deleted", "modified"]),
  212. }).annotate({ identifier: "VcsFileStatus" })
  213. export type FileStatus = Schema.Schema.Type<typeof FileStatus>
  214. export const ApplyInput = Schema.Struct({
  215. patch: Schema.String,
  216. })
  217. export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
  218. export const ApplyResult = Schema.Struct({
  219. applied: Schema.Boolean,
  220. })
  221. export type ApplyResult = Schema.Schema.Type<typeof ApplyResult>
  222. export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()("VcsPatchApplyError", {
  223. message: Schema.String,
  224. reason: Schema.Literals(["non-git", "not-clean"]),
  225. }) {}
  226. export interface Interface {
  227. readonly init: () => Effect.Effect<void>
  228. readonly branch: () => Effect.Effect<string | undefined>
  229. readonly defaultBranch: () => Effect.Effect<string | undefined>
  230. readonly status: () => Effect.Effect<FileStatus[]>
  231. readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
  232. readonly diffRaw: () => Effect.Effect<string>
  233. readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
  234. }
  235. interface State {
  236. current: string | undefined
  237. root: Git.Base | undefined
  238. }
  239. export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
  240. export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
  241. Service,
  242. Effect.gen(function* () {
  243. const git = yield* Git.Service
  244. const bus = yield* Bus.Service
  245. const scope = yield* Scope.Scope
  246. const state = yield* InstanceState.make<State>(
  247. Effect.fn("Vcs.state")(function* (ctx) {
  248. if (ctx.project.vcs !== "git") {
  249. return { current: undefined, root: undefined }
  250. }
  251. const get = Effect.fnUntraced(function* () {
  252. return yield* git.branch(ctx.directory)
  253. })
  254. const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
  255. concurrency: 2,
  256. })
  257. const value = { current, root }
  258. log.info("initialized", { branch: value.current, default_branch: value.root?.name })
  259. yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
  260. Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
  261. Stream.runForEach((_evt) =>
  262. Effect.gen(function* () {
  263. const next = yield* get()
  264. if (next !== value.current) {
  265. log.info("branch changed", { from: value.current, to: next })
  266. value.current = next
  267. yield* bus.publish(Event.BranchUpdated, { branch: next })
  268. }
  269. }),
  270. ),
  271. Effect.forkScoped,
  272. )
  273. return value
  274. }),
  275. )
  276. return Service.of({
  277. init: Effect.fn("Vcs.init")(function* () {
  278. yield* InstanceState.get(state).pipe(Effect.forkIn(scope))
  279. }),
  280. branch: Effect.fn("Vcs.branch")(function* () {
  281. return yield* InstanceState.use(state, (x) => x.current)
  282. }),
  283. defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
  284. return yield* InstanceState.use(state, (x) => x.root?.name)
  285. }),
  286. status: Effect.fn("Vcs.status")(function* () {
  287. const ctx = yield* InstanceState.context
  288. if (ctx.project.vcs !== "git") return []
  289. const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
  290. const [list, stats] = yield* Effect.all(
  291. [git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])],
  292. { concurrency: 2 },
  293. )
  294. const map = nums(stats)
  295. return yield* Effect.forEach(
  296. list.toSorted((a, b) => a.file.localeCompare(b.file)),
  297. (item) =>
  298. Effect.gen(function* () {
  299. const stat =
  300. map.get(item.file) ??
  301. (item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined)
  302. return {
  303. file: item.file,
  304. additions: stat?.additions ?? 0,
  305. deletions: stat?.deletions ?? 0,
  306. status: item.status,
  307. } satisfies FileStatus
  308. }),
  309. )
  310. }),
  311. diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
  312. const value = yield* InstanceState.get(state)
  313. const ctx = yield* InstanceState.context
  314. if (ctx.project.vcs !== "git") return []
  315. if (mode === "git") {
  316. return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
  317. }
  318. if (!value.root) return []
  319. if (value.current && value.current === value.root.name) return []
  320. const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
  321. if (!ref) return []
  322. return yield* diffAgainstRef(git, ctx.directory, ref)
  323. }),
  324. diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
  325. const ctx = yield* InstanceState.context
  326. if (ctx.project.vcs !== "git") return ""
  327. const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], {
  328. concurrency: 2,
  329. })
  330. const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : ""
  331. const untracked = yield* Effect.forEach(
  332. status.filter((item) => item.code === "??"),
  333. (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)),
  334. )
  335. return [tracked, ...untracked].filter(Boolean).join("\n")
  336. }),
  337. apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) {
  338. const ctx = yield* InstanceState.context
  339. if (ctx.project.vcs !== "git") {
  340. return yield* new PatchApplyError({
  341. message: "Patch can't be applied because the project is not git-based",
  342. reason: "non-git",
  343. })
  344. }
  345. const applied = yield* git.applyPatch(ctx.directory, input.patch)
  346. if (applied.exitCode !== 0) {
  347. return yield* new PatchApplyError({
  348. message: "Patch can't be applied",
  349. reason: "not-clean",
  350. })
  351. }
  352. return { applied: true }
  353. }),
  354. })
  355. }),
  356. )
  357. export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
  358. export * as Vcs from "./vcs"