project.test.ts 7.3 KB

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