project.test.ts 8.6 KB

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