index.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
  2. import { Effect, Layer, Context, Stream } from "effect"
  3. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  4. export namespace Git {
  5. const cfg = [
  6. "--no-optional-locks",
  7. "-c",
  8. "core.autocrlf=false",
  9. "-c",
  10. "core.fsmonitor=false",
  11. "-c",
  12. "core.longpaths=true",
  13. "-c",
  14. "core.symlinks=true",
  15. "-c",
  16. "core.quotepath=false",
  17. ] as const
  18. const out = (result: { text(): string }) => result.text().trim()
  19. const nuls = (text: string) => text.split("\0").filter(Boolean)
  20. const fail = (err: unknown) =>
  21. ({
  22. exitCode: 1,
  23. text: () => "",
  24. stdout: Buffer.alloc(0),
  25. stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
  26. }) satisfies Result
  27. export type Kind = "added" | "deleted" | "modified"
  28. export type Base = {
  29. readonly name: string
  30. readonly ref: string
  31. }
  32. export type Item = {
  33. readonly file: string
  34. readonly code: string
  35. readonly status: Kind
  36. }
  37. export type Stat = {
  38. readonly file: string
  39. readonly additions: number
  40. readonly deletions: number
  41. }
  42. export interface Result {
  43. readonly exitCode: number
  44. readonly text: () => string
  45. readonly stdout: Buffer
  46. readonly stderr: Buffer
  47. }
  48. export interface Options {
  49. readonly cwd: string
  50. readonly env?: Record<string, string>
  51. }
  52. export interface Interface {
  53. readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
  54. readonly branch: (cwd: string) => Effect.Effect<string | undefined>
  55. readonly prefix: (cwd: string) => Effect.Effect<string>
  56. readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
  57. readonly hasHead: (cwd: string) => Effect.Effect<boolean>
  58. readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
  59. readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
  60. readonly status: (cwd: string) => Effect.Effect<Item[]>
  61. readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
  62. readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
  63. }
  64. const kind = (code: string): Kind => {
  65. if (code === "??") return "added"
  66. if (code.includes("U")) return "modified"
  67. if (code.includes("A") && !code.includes("D")) return "added"
  68. if (code.includes("D") && !code.includes("A")) return "deleted"
  69. return "modified"
  70. }
  71. export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
  72. export const layer = Layer.effect(
  73. Service,
  74. Effect.gen(function* () {
  75. const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
  76. const run = Effect.fn("Git.run")(
  77. function* (args: string[], opts: Options) {
  78. const proc = ChildProcess.make("git", [...cfg, ...args], {
  79. cwd: opts.cwd,
  80. env: opts.env,
  81. extendEnv: true,
  82. stdin: "ignore",
  83. stdout: "pipe",
  84. stderr: "pipe",
  85. })
  86. const handle = yield* spawner.spawn(proc)
  87. const [stdout, stderr] = yield* Effect.all(
  88. [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
  89. { concurrency: 2 },
  90. )
  91. return {
  92. exitCode: yield* handle.exitCode,
  93. text: () => stdout,
  94. stdout: Buffer.from(stdout),
  95. stderr: Buffer.from(stderr),
  96. } satisfies Result
  97. },
  98. Effect.scoped,
  99. Effect.catch((err) => Effect.succeed(fail(err))),
  100. )
  101. const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
  102. return (yield* run(args, opts)).text()
  103. })
  104. const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
  105. return (yield* text(args, opts))
  106. .split(/\r?\n/)
  107. .map((item) => item.trim())
  108. .filter(Boolean)
  109. })
  110. const refs = Effect.fnUntraced(function* (cwd: string) {
  111. return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
  112. })
  113. const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
  114. const result = yield* run(["config", "init.defaultBranch"], { cwd })
  115. const name = out(result)
  116. if (!name || !list.includes(name)) return
  117. return { name, ref: name } satisfies Base
  118. })
  119. const primary = Effect.fnUntraced(function* (cwd: string) {
  120. const list = yield* lines(["remote"], { cwd })
  121. if (list.includes("origin")) return "origin"
  122. if (list.length === 1) return list[0]
  123. if (list.includes("upstream")) return "upstream"
  124. return list[0]
  125. })
  126. const branch = Effect.fn("Git.branch")(function* (cwd: string) {
  127. const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
  128. if (result.exitCode !== 0) return
  129. const text = out(result)
  130. return text || undefined
  131. })
  132. const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
  133. const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
  134. if (result.exitCode !== 0) return ""
  135. return out(result)
  136. })
  137. const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
  138. const remote = yield* primary(cwd)
  139. if (remote) {
  140. const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
  141. if (head.exitCode === 0) {
  142. const ref = out(head).replace(/^refs\/remotes\//, "")
  143. const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
  144. if (name) return { name, ref } satisfies Base
  145. }
  146. }
  147. const list = yield* refs(cwd)
  148. const next = yield* configured(cwd, list)
  149. if (next) return next
  150. if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
  151. if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
  152. })
  153. const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
  154. const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
  155. return result.exitCode === 0
  156. })
  157. const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
  158. const result = yield* run(["merge-base", base, head], { cwd })
  159. if (result.exitCode !== 0) return
  160. const text = out(result)
  161. return text || undefined
  162. })
  163. const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
  164. const target = prefix ? `${prefix}${file}` : file
  165. const result = yield* run(["show", `${ref}:${target}`], { cwd })
  166. if (result.exitCode !== 0) return ""
  167. if (result.stdout.includes(0)) return ""
  168. return result.text()
  169. })
  170. const status = Effect.fn("Git.status")(function* (cwd: string) {
  171. return nuls(
  172. yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
  173. cwd,
  174. }),
  175. ).flatMap((item) => {
  176. const file = item.slice(3)
  177. if (!file) return []
  178. const code = item.slice(0, 2)
  179. return [{ file, code, status: kind(code) } satisfies Item]
  180. })
  181. })
  182. const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
  183. const list = nuls(
  184. yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
  185. )
  186. return list.flatMap((code, idx) => {
  187. if (idx % 2 !== 0) return []
  188. const file = list[idx + 1]
  189. if (!code || !file) return []
  190. return [{ file, code, status: kind(code) } satisfies Item]
  191. })
  192. })
  193. const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
  194. return nuls(
  195. yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
  196. ).flatMap((item) => {
  197. const a = item.indexOf("\t")
  198. const b = item.indexOf("\t", a + 1)
  199. if (a === -1 || b === -1) return []
  200. const file = item.slice(b + 1)
  201. if (!file) return []
  202. const adds = item.slice(0, a)
  203. const dels = item.slice(a + 1, b)
  204. const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
  205. const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
  206. return [
  207. {
  208. file,
  209. additions: Number.isFinite(additions) ? additions : 0,
  210. deletions: Number.isFinite(deletions) ? deletions : 0,
  211. } satisfies Stat,
  212. ]
  213. })
  214. })
  215. return Service.of({
  216. run,
  217. branch,
  218. prefix,
  219. defaultBranch,
  220. hasHead,
  221. mergeBase,
  222. show,
  223. status,
  224. diff,
  225. stats,
  226. })
  227. }),
  228. )
  229. export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
  230. }