git.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. export * as Git from "./git"
  2. import path from "path"
  3. import { Context, Effect, Layer } from "effect"
  4. import { ChildProcess } from "effect/unstable/process"
  5. import { AbsolutePath } from "./schema"
  6. import { AppFileSystem } from "./filesystem"
  7. import { AppProcess } from "./process"
  8. export interface Repo {
  9. /**
  10. * The root directory of the working tree that contains the input path.
  11. *
  12. * For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
  13. * For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
  14. * `/home/me/app-feature`.
  15. */
  16. readonly directory: AbsolutePath
  17. /**
  18. * The shared Git storage directory used by this repo and any linked worktrees.
  19. *
  20. * For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
  21. * For a linked worktree at `/home/me/app-feature` whose main checkout is
  22. * `/home/me/app`, this is usually `/home/me/app/.git`.
  23. */
  24. readonly store: AbsolutePath
  25. }
  26. export interface Interface {
  27. readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
  28. readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
  29. readonly roots: (repo: Repo) => Effect.Effect<string[]>
  30. }
  31. export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
  32. export const layer = Layer.effect(
  33. Service,
  34. Effect.gen(function* () {
  35. const fs = yield* AppFileSystem.Service
  36. const proc = yield* AppProcess.Service
  37. const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
  38. const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
  39. Effect.map((matches) => matches[0]),
  40. Effect.catch(() => Effect.succeed(undefined)),
  41. )
  42. if (!dotgit) return undefined
  43. const cwd = path.dirname(dotgit)
  44. const git = run(cwd, proc)
  45. const topLevel = yield* git(["rev-parse", "--show-toplevel"])
  46. const commonDir = yield* git(["rev-parse", "--git-common-dir"])
  47. if (commonDir.exitCode !== 0) return undefined
  48. return {
  49. directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
  50. store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
  51. } satisfies Repo
  52. })
  53. const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
  54. const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
  55. if (result.exitCode !== 0) return undefined
  56. return result.text.trim() || undefined
  57. })
  58. const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
  59. const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
  60. if (result.exitCode !== 0) return []
  61. return result.text
  62. .split("\n")
  63. .map((item) => item.trim())
  64. .filter(Boolean)
  65. .toSorted()
  66. })
  67. return Service.of({ find, remote, roots })
  68. }),
  69. )
  70. export const defaultLayer = layer.pipe(
  71. Layer.provide(AppFileSystem.defaultLayer),
  72. Layer.provide(AppProcess.defaultLayer),
  73. )
  74. interface Result {
  75. readonly exitCode: number
  76. readonly text: string
  77. }
  78. function run(cwd: string, proc: AppProcess.Interface) {
  79. return (args: string[]) =>
  80. proc
  81. .run(
  82. ChildProcess.make("git", args, {
  83. cwd,
  84. extendEnv: true,
  85. stdin: "ignore",
  86. }),
  87. )
  88. .pipe(
  89. Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result),
  90. Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
  91. )
  92. }
  93. function resolvePath(cwd: string, value: string) {
  94. const trimmed = value.replace(/[\r\n]+$/, "")
  95. if (!trimmed) return cwd
  96. const normalized = AppFileSystem.windowsPath(trimmed)
  97. if (path.isAbsolute(normalized)) return path.normalize(normalized)
  98. return path.resolve(cwd, normalized)
  99. }