git.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. export * as Git from "./git"
  2. import path from "path"
  3. import { Context, Effect, Layer, Schema, Stream } from "effect"
  4. import { ChildProcess } from "effect/unstable/process"
  5. import { AbsolutePath } from "./schema"
  6. import { FSUtil } from "./fs-util"
  7. import { AppProcess } from "./process"
  8. import { LayerNode } from "./effect/layer-node"
  9. export interface Repo {
  10. /**
  11. * The root directory of the working tree that contains the input path.
  12. *
  13. * For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
  14. * For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
  15. * `/home/me/app-feature`.
  16. */
  17. readonly directory: AbsolutePath
  18. /**
  19. * The shared Git storage directory used by this repo and any linked worktrees.
  20. *
  21. * For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
  22. * For a linked worktree at `/home/me/app-feature` whose main checkout is
  23. * `/home/me/app`, this is usually `/home/me/app/.git`.
  24. */
  25. readonly store: AbsolutePath
  26. }
  27. export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
  28. operation: Schema.Literals(["create", "remove", "list"]),
  29. message: Schema.String,
  30. directory: Schema.optional(AbsolutePath),
  31. forceRequired: Schema.optional(Schema.Boolean),
  32. cause: Schema.optional(Schema.Defect()),
  33. }) {}
  34. export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
  35. operation: Schema.Literals(["capture", "apply", "reset"]),
  36. directory: AbsolutePath,
  37. message: Schema.String,
  38. cause: Schema.optional(Schema.Defect()),
  39. }) {}
  40. export interface Interface {
  41. readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
  42. readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
  43. readonly roots: (repo: Repo) => Effect.Effect<string[]>
  44. readonly origin: (directory: string) => Effect.Effect<string | undefined>
  45. readonly head: (directory: string) => Effect.Effect<string | undefined>
  46. readonly dir: (directory: string) => Effect.Effect<string | undefined>
  47. readonly branch: (directory: string) => Effect.Effect<string | undefined>
  48. readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
  49. readonly clone: (input: {
  50. remote: string
  51. target: string
  52. branch?: string
  53. depth?: number
  54. }) => Effect.Effect<Result, AppProcess.AppProcessError>
  55. readonly fetch: (directory: string) => Effect.Effect<Result, AppProcess.AppProcessError>
  56. readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
  57. readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
  58. readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
  59. readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
  60. readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
  61. readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
  62. readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
  63. readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
  64. readonly worktreeRemove: (input: {
  65. repo: Repo
  66. directory: AbsolutePath
  67. force: boolean
  68. }) => Effect.Effect<void, WorktreeError>
  69. readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
  70. }
  71. export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
  72. export const layer = Layer.effect(
  73. Service,
  74. Effect.gen(function* () {
  75. const fs = yield* FSUtil.Service
  76. const proc = yield* AppProcess.Service
  77. const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
  78. const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
  79. Effect.map((matches) => matches[0]),
  80. Effect.catch(() => Effect.succeed(undefined)),
  81. )
  82. if (!dotgit) return undefined
  83. const cwd = path.dirname(dotgit)
  84. const git = run(cwd, proc)
  85. const topLevel = yield* git(["rev-parse", "--show-toplevel"])
  86. const commonDir = yield* git(["rev-parse", "--git-common-dir"])
  87. if (commonDir.exitCode !== 0) return undefined
  88. return {
  89. directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
  90. store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
  91. } satisfies Repo
  92. })
  93. const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
  94. const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
  95. if (result.exitCode !== 0) return undefined
  96. return result.text.trim() || undefined
  97. })
  98. const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
  99. const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
  100. if (result.exitCode !== 0) return []
  101. return result.text
  102. .split("\n")
  103. .map((item) => item.trim())
  104. .filter(Boolean)
  105. .toSorted()
  106. })
  107. const origin = Effect.fn("Git.origin")(function* (directory: string) {
  108. const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"])
  109. if (result.exitCode !== 0) return undefined
  110. return result.text.trim() || undefined
  111. })
  112. const head = Effect.fn("Git.head")(function* (directory: string) {
  113. const result = yield* run(directory, proc)(["rev-parse", "HEAD"])
  114. if (result.exitCode !== 0) return undefined
  115. return result.text.trim() || undefined
  116. })
  117. const dir = Effect.fn("Git.dir")(function* (directory: string) {
  118. const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
  119. if (result.exitCode !== 0) return undefined
  120. return AbsolutePath.make(resolvePath(directory, result.text))
  121. })
  122. const branch = Effect.fn("Git.branch")(function* (directory: string) {
  123. const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
  124. if (result.exitCode !== 0) return undefined
  125. return result.text.trim() || undefined
  126. })
  127. const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) {
  128. const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"])
  129. if (result.exitCode !== 0) return undefined
  130. return result.text.trim().replace(/^refs\/remotes\//, "") || undefined
  131. })
  132. const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) =>
  133. execute(
  134. path.dirname(input.target),
  135. proc,
  136. )([
  137. "clone",
  138. "--depth",
  139. String(input.depth ?? 100),
  140. ...(input.branch ? ["--branch", input.branch] : []),
  141. "--",
  142. input.remote,
  143. input.target,
  144. ]),
  145. )
  146. const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"]))
  147. const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) =>
  148. execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]),
  149. )
  150. const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) =>
  151. execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]),
  152. )
  153. const reset = Effect.fn("Git.reset")((directory: string, target: string) =>
  154. execute(directory, proc)(["reset", "--hard", target]),
  155. )
  156. const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
  157. const root = yield* execute(
  158. directory,
  159. proc,
  160. )(["rev-parse", "--show-toplevel"]).pipe(
  161. Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
  162. )
  163. if (root.exitCode !== 0) {
  164. return yield* new PatchError({
  165. operation: "capture",
  166. directory,
  167. message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root",
  168. })
  169. }
  170. const repo = AbsolutePath.make(resolvePath(directory, root.text))
  171. const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
  172. const tracked = yield* execute(
  173. repo,
  174. proc,
  175. )(["diff", "--binary", "HEAD", "--", scope]).pipe(
  176. Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
  177. )
  178. if (tracked.exitCode !== 0) {
  179. return yield* new PatchError({
  180. operation: "capture",
  181. directory,
  182. message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
  183. })
  184. }
  185. const untracked = yield* execute(
  186. repo,
  187. proc,
  188. )(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
  189. Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
  190. )
  191. if (untracked.exitCode !== 0) {
  192. return yield* new PatchError({
  193. operation: "capture",
  194. directory,
  195. message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
  196. })
  197. }
  198. const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
  199. execute(
  200. repo,
  201. proc,
  202. )(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
  203. Effect.mapError(
  204. (cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }),
  205. ),
  206. Effect.flatMap((result) =>
  207. // git diff --no-index returns 1 when differences were found.
  208. result.exitCode === 0 || result.exitCode === 1
  209. ? Effect.succeed(result.text)
  210. : Effect.fail(
  211. new PatchError({
  212. operation: "capture",
  213. directory,
  214. message:
  215. result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
  216. }),
  217. ),
  218. ),
  219. ),
  220. )
  221. return [tracked.text, ...created].filter(Boolean).join("\n")
  222. })
  223. const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
  224. const result = yield* proc
  225. .run(
  226. ChildProcess.make("git", ["apply", "-"], {
  227. cwd: input.directory,
  228. extendEnv: true,
  229. stdin: Stream.make(new TextEncoder().encode(input.patch)),
  230. }),
  231. )
  232. .pipe(
  233. Effect.mapError(
  234. (cause) =>
  235. new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
  236. ),
  237. )
  238. if (result.exitCode === 0) return
  239. return yield* new PatchError({
  240. operation: "apply",
  241. directory: input.directory,
  242. message:
  243. result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
  244. })
  245. })
  246. const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
  247. const reset = yield* execute(
  248. directory,
  249. proc,
  250. )(["reset", "--hard", "HEAD"]).pipe(
  251. Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
  252. )
  253. if (reset.exitCode !== 0) {
  254. return yield* new PatchError({
  255. operation: "reset",
  256. directory,
  257. message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
  258. })
  259. }
  260. const clean = yield* execute(
  261. directory,
  262. proc,
  263. )(["clean", "-fd"]).pipe(
  264. Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
  265. )
  266. if (clean.exitCode === 0) return
  267. return yield* new PatchError({
  268. operation: "reset",
  269. directory,
  270. message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
  271. })
  272. })
  273. const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) {
  274. const checkout = yield* execute(
  275. directory,
  276. proc,
  277. )(["checkout", "--", "."]).pipe(
  278. Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
  279. )
  280. if (checkout.exitCode !== 0) {
  281. return yield* new PatchError({
  282. operation: "reset",
  283. directory,
  284. message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes",
  285. })
  286. }
  287. const clean = yield* execute(
  288. directory,
  289. proc,
  290. )(["clean", "-fd", "--", "."]).pipe(
  291. Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
  292. )
  293. if (clean.exitCode === 0) return
  294. return yield* new PatchError({
  295. operation: "reset",
  296. directory,
  297. message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
  298. })
  299. })
  300. const worktree = Effect.fnUntraced(function* (
  301. operation: "create" | "remove" | "list",
  302. repo: Repo,
  303. args: string[],
  304. worktreeDirectory?: AbsolutePath,
  305. cwd = repo.directory,
  306. ) {
  307. const result = yield* proc
  308. .run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
  309. .pipe(
  310. Effect.mapError(
  311. (cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
  312. ),
  313. )
  314. if (result.exitCode === 0) return result.stdout.toString("utf8")
  315. const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed"
  316. return yield* new WorktreeError({
  317. operation,
  318. directory: worktreeDirectory,
  319. message,
  320. forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
  321. })
  322. })
  323. const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
  324. yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
  325. })
  326. const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
  327. repo: Repo
  328. directory: AbsolutePath
  329. force: boolean
  330. }) {
  331. yield* worktree(
  332. "remove",
  333. input.repo,
  334. ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
  335. input.directory,
  336. input.repo.store,
  337. )
  338. })
  339. const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
  340. return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
  341. .split("\n")
  342. .filter((line) => line.startsWith("worktree "))
  343. .map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
  344. })
  345. return Service.of({
  346. find,
  347. remote,
  348. roots,
  349. origin,
  350. head,
  351. dir,
  352. branch,
  353. remoteHead,
  354. clone,
  355. fetch,
  356. fetchBranch,
  357. checkout,
  358. reset,
  359. patch,
  360. applyPatch,
  361. resetChanges,
  362. softResetChanges,
  363. worktreeCreate,
  364. worktreeRemove,
  365. worktreeList,
  366. })
  367. }),
  368. )
  369. export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
  370. export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
  371. export interface Result {
  372. readonly exitCode: number
  373. readonly text: string
  374. readonly stderr: string
  375. }
  376. function run(cwd: string, proc: AppProcess.Interface) {
  377. return (args: string[]) =>
  378. execute(cwd, proc)(args).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, text: "", stderr: "" })))
  379. }
  380. function execute(cwd: string, proc: AppProcess.Interface) {
  381. return (args: string[]) =>
  382. proc
  383. .run(
  384. ChildProcess.make("git", args, {
  385. cwd,
  386. extendEnv: true,
  387. stdin: "ignore",
  388. }),
  389. )
  390. .pipe(
  391. Effect.map(
  392. (result) =>
  393. ({
  394. exitCode: result.exitCode,
  395. text: result.stdout.toString("utf8"),
  396. stderr: result.stderr.toString("utf8"),
  397. }) satisfies Result,
  398. ),
  399. )
  400. }
  401. function resolvePath(cwd: string, value: string) {
  402. const trimmed = value.replace(/[\r\n]+$/, "")
  403. if (!trimmed) return cwd
  404. const normalized = FSUtil.windowsPath(trimmed)
  405. if (path.isAbsolute(normalized)) return path.normalize(normalized)
  406. return path.resolve(cwd, normalized)
  407. }