git.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { describe, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { Effect } from "effect"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Git } from "@opencode-ai/core/git"
  8. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  9. import { branch, commit, gitRemote } from "./fixture/git"
  10. import { tmpdir } from "./fixture/tmpdir"
  11. import { testEffect } from "./lib/effect"
  12. const it = testEffect(LayerNode.compile(Git.node))
  13. describe("Git", () => {
  14. it.live("discovers repository metadata without a work tree", () =>
  15. Effect.gen(function* () {
  16. const root = yield* Effect.acquireRelease(
  17. Effect.promise(() => tmpdir()),
  18. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  19. )
  20. yield* Effect.promise(async () => {
  21. await initRepo(root.path)
  22. await $`git config core.bare true`.cwd(root.path).quiet()
  23. })
  24. const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
  25. const git = yield* Git.Service
  26. const repository = yield* git.repo.discover(directory)
  27. expect(repository?.worktree).toBe(directory)
  28. expect(repository?.gitDirectory).toBe(AbsolutePath.make(path.join(directory, ".git")))
  29. expect(repository?.commonDirectory).toBe(repository?.gitDirectory)
  30. }),
  31. )
  32. it.live("clones a remote and reads checkout metadata", () =>
  33. withRemote((fixture) =>
  34. Effect.gen(function* () {
  35. const git = yield* Git.Service
  36. const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
  37. const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
  38. expect(yield* git.remote.get(repository)).toBe(fixture.remote)
  39. expect(yield* git.history.head(repository)).toBeString()
  40. expect(yield* git.history.branch(repository)).toBe("main")
  41. expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main")
  42. expect(repository.worktree).toBe(target)
  43. expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git")))
  44. expect(repository.commonDirectory).toBe(repository.gitDirectory)
  45. expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
  46. }),
  47. ),
  48. )
  49. it.live("fetches, checks out, and resets remote changes", () =>
  50. withRemote((fixture) =>
  51. Effect.gen(function* () {
  52. const git = yield* Git.Service
  53. const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
  54. const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
  55. yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
  56. yield* git.sync.fetchRemotes(repository)
  57. yield* git.sync.resetHard(repository, "origin/main")
  58. expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
  59. yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
  60. yield* git.sync.fetchBranch(repository, { branch: "feature/docs" })
  61. yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" })
  62. yield* git.sync.resetHard(repository, "origin/feature/docs")
  63. expect(yield* git.history.branch(repository)).toBe("feature/docs")
  64. expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
  65. }),
  66. ),
  67. )
  68. })
  69. function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
  70. return Effect.acquireUseRelease(
  71. Effect.promise(async () => {
  72. const root = await tmpdir()
  73. return { root, fixture: await gitRemote(root.path) }
  74. }),
  75. (input) => body(input.fixture),
  76. (input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
  77. )
  78. }
  79. function read(file: string) {
  80. return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
  81. }
  82. async function initRepo(directory: string) {
  83. await $`git init`.cwd(directory).quiet()
  84. await $`git config core.fsmonitor false`.cwd(directory).quiet()
  85. await $`git config commit.gpgsign false`.cwd(directory).quiet()
  86. await $`git config user.email test@opencode.test`.cwd(directory).quiet()
  87. await $`git config user.name Test`.cwd(directory).quiet()
  88. await $`git commit --allow-empty -m root`.cwd(directory).quiet()
  89. }
  90. describe("Git worktrees", () => {
  91. it.live("creates, lists, and removes linked worktrees", () =>
  92. Effect.gen(function* () {
  93. const root = yield* Effect.acquireRelease(
  94. Effect.promise(() => tmpdir()),
  95. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  96. )
  97. yield* Effect.promise(() => initRepo(root.path))
  98. const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
  99. const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
  100. yield* Effect.addFinalizer(() =>
  101. Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
  102. )
  103. const git = yield* Git.Service
  104. const repo = yield* git.repo.discover(directory)
  105. if (!repo) throw new Error("Repository not found")
  106. yield* git.worktree.create({ repository: repo, directory: worktree })
  107. expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(true)
  108. const linked = yield* git.repo.discover(worktree)
  109. expect(linked?.worktree).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
  110. expect(linked?.commonDirectory).toBe(repo.commonDirectory)
  111. expect(linked?.gitDirectory).not.toBe(repo.gitDirectory)
  112. if (!linked) throw new Error("Linked worktree not found")
  113. yield* git.worktree.remove({ repository: linked, directory: worktree, force: false })
  114. expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(false)
  115. }),
  116. )
  117. })
  118. describe("Git trees", () => {
  119. it.live("captures, compares, previews, and restores scoped trees", () =>
  120. Effect.gen(function* () {
  121. const root = yield* Effect.acquireRelease(
  122. Effect.promise(() => tmpdir()),
  123. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  124. )
  125. yield* Effect.promise(async () => {
  126. await initRepo(root.path)
  127. await fs.mkdir(path.join(root.path, "scope"))
  128. await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "one\n")
  129. await fs.writeFile(path.join(root.path, "outside.txt"), "outside\n")
  130. await $`git add .`.cwd(root.path).quiet()
  131. await $`git commit -m initial`.cwd(root.path).quiet()
  132. })
  133. const git = yield* Git.Service
  134. const source = yield* git.repo.discover(AbsolutePath.make(root.path))
  135. if (!source) throw new Error("Repository not found")
  136. const storage = AbsolutePath.make(path.join(root.path, ".snapshot"))
  137. const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
  138. yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
  139. const before = yield* git.tree.write(repository)
  140. yield* Effect.promise(async () => {
  141. await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "two\n")
  142. await fs.writeFile(path.join(root.path, "scope", "added.txt"), "added\n")
  143. await fs.writeFile(path.join(root.path, "outside.txt"), "changed outside\n")
  144. })
  145. yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
  146. const after = yield* git.tree.write(repository)
  147. expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
  148. RelativePath.make("scope/added.txt"),
  149. RelativePath.make("scope/tracked.txt"),
  150. ])
  151. const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
  152. expect(diffs.map((item) => [item.file, item.status])).toEqual([
  153. [RelativePath.make("scope/added.txt"), "added"],
  154. [RelativePath.make("scope/tracked.txt"), "modified"],
  155. ])
  156. const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
  157. const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
  158. expect(preview).toHaveLength(1)
  159. expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
  160. yield* git.tree.restore({ repository, files })
  161. expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
  162. expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
  163. expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n")
  164. }),
  165. )
  166. })