project.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  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 { Project } from "@opencode-ai/core/project"
  7. import { AbsolutePath } from "@opencode-ai/core/schema"
  8. import { Hash } from "@opencode-ai/core/util/hash"
  9. import { tmpdir } from "./fixture/tmpdir"
  10. import { testEffect } from "./lib/effect"
  11. const it = testEffect(Project.defaultLayer)
  12. function remoteID(remote: string) {
  13. return Project.ID.make(Hash.fast(`git-remote:${remote}`))
  14. }
  15. function abs(value: string) {
  16. return AbsolutePath.make(value)
  17. }
  18. function real(value: string) {
  19. return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
  20. }
  21. async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
  22. await $`git init`.cwd(dir).quiet()
  23. await $`git config core.fsmonitor false`.cwd(dir).quiet()
  24. await $`git config commit.gpgsign false`.cwd(dir).quiet()
  25. await $`git config user.email test@opencode.test`.cwd(dir).quiet()
  26. await $`git config user.name Test`.cwd(dir).quiet()
  27. if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
  28. if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
  29. }
  30. async function rootCommit(dir: string) {
  31. return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
  32. }
  33. describe("ProjectV2.resolve", () => {
  34. it.live("returns global for non-git directory", () =>
  35. Effect.gen(function* () {
  36. const tmp = yield* Effect.acquireRelease(
  37. Effect.promise(() => tmpdir()),
  38. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  39. )
  40. const project = yield* Project.Service
  41. const result = yield* project.resolve(abs(tmp.path))
  42. expect(result.id).toBe(Project.ID.make("global"))
  43. expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
  44. expect(result.previous).toBeUndefined()
  45. expect(result.vcs).toBeUndefined()
  46. }),
  47. )
  48. it.live("returns git global for repo with no commits and no remote", () =>
  49. Effect.gen(function* () {
  50. const tmp = yield* Effect.acquireRelease(
  51. Effect.promise(() => tmpdir()),
  52. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  53. )
  54. yield* Effect.promise(() => initRepo(tmp.path))
  55. const project = yield* Project.Service
  56. const result = yield* project.resolve(abs(tmp.path))
  57. expect(result.id).toBe(Project.ID.make("global"))
  58. expect(result.directory).toBe(yield* real(tmp.path))
  59. expect(result.previous).toBeUndefined()
  60. expect(result.vcs?.type).toBe("git")
  61. }),
  62. )
  63. it.live("falls back to root commit when origin is missing", () =>
  64. Effect.gen(function* () {
  65. const tmp = yield* Effect.acquireRelease(
  66. Effect.promise(() => tmpdir()),
  67. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  68. )
  69. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  70. const project = yield* Project.Service
  71. const result = yield* project.resolve(abs(tmp.path))
  72. expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  73. expect(result.directory).toBe(yield* real(tmp.path))
  74. expect(result.previous).toBeUndefined()
  75. expect(result.vcs?.type).toBe("git")
  76. }),
  77. )
  78. it.live("prefers normalized origin over root commit", () =>
  79. Effect.gen(function* () {
  80. const tmp = yield* Effect.acquireRelease(
  81. Effect.promise(() => tmpdir()),
  82. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  83. )
  84. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
  85. const project = yield* Project.Service
  86. const result = yield* project.resolve(abs(tmp.path))
  87. expect(result.id).toBe(remoteID("github.com/Acme/App"))
  88. expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  89. expect(result.directory).toBe(yield* real(tmp.path))
  90. expect(result.vcs?.type).toBe("git")
  91. }),
  92. )
  93. it.live("normalizes ssh and https remotes to the same id", () =>
  94. Effect.gen(function* () {
  95. const ssh = yield* Effect.acquireRelease(
  96. Effect.promise(() => tmpdir()),
  97. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  98. )
  99. const https = yield* Effect.acquireRelease(
  100. Effect.promise(() => tmpdir()),
  101. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  102. )
  103. yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  104. yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
  105. const project = yield* Project.Service
  106. const a = yield* project.resolve(abs(ssh.path))
  107. const b = yield* project.resolve(abs(https.path))
  108. expect(a.id).toBe(remoteID("github.com/owner/repo"))
  109. expect(b.id).toBe(a.id)
  110. }),
  111. )
  112. it.live("ignores file remotes and falls back to root commit", () =>
  113. Effect.gen(function* () {
  114. const tmp = yield* Effect.acquireRelease(
  115. Effect.promise(() => tmpdir()),
  116. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  117. )
  118. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
  119. const project = yield* Project.Service
  120. const result = yield* project.resolve(abs(tmp.path))
  121. expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
  122. }),
  123. )
  124. it.live("returns previous cached id from common dir", () =>
  125. Effect.gen(function* () {
  126. const tmp = yield* Effect.acquireRelease(
  127. Effect.promise(() => tmpdir()),
  128. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  129. )
  130. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  131. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  132. const project = yield* Project.Service
  133. const result = yield* project.resolve(abs(tmp.path))
  134. expect(result.previous).toBe(Project.ID.make("old-id"))
  135. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  136. }),
  137. )
  138. it.live("does not write the cache while resolving", () =>
  139. Effect.gen(function* () {
  140. const tmp = yield* Effect.acquireRelease(
  141. Effect.promise(() => tmpdir()),
  142. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  143. )
  144. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  145. const project = yield* Project.Service
  146. yield* project.resolve(abs(tmp.path))
  147. expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
  148. }),
  149. )
  150. it.live("resolves from nested directories to repo root", () =>
  151. Effect.gen(function* () {
  152. const tmp = yield* Effect.acquireRelease(
  153. Effect.promise(() => tmpdir()),
  154. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  155. )
  156. yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
  157. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
  158. const project = yield* Project.Service
  159. const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
  160. expect(result.directory).toBe(yield* real(tmp.path))
  161. }),
  162. )
  163. it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
  164. Effect.gen(function* () {
  165. const tmp = yield* Effect.acquireRelease(
  166. Effect.promise(() => tmpdir()),
  167. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  168. )
  169. const worktree = `${tmp.path}-worktree`
  170. yield* Effect.addFinalizer(() =>
  171. Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
  172. )
  173. yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
  174. yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
  175. yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
  176. const project = yield* Project.Service
  177. const result = yield* project.resolve(abs(worktree))
  178. expect(result.directory).toBe(yield* real(worktree))
  179. expect(result.previous).toBe(Project.ID.make("old-id"))
  180. expect(result.id).toBe(remoteID("github.com/owner/repo"))
  181. expect(result.vcs?.type).toBe("git")
  182. }),
  183. )
  184. })