worktree.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. import { describe, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { and, eq, isNull } from "drizzle-orm"
  6. import { Effect, Fiber, Stream } from "effect"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { Git } from "@opencode-ai/core/git"
  11. import { Database } from "@opencode-ai/core/database/database"
  12. import { Bus } from "@opencode-ai/core/bus"
  13. import { Project } from "@opencode-ai/core/project"
  14. import { ProjectTable } from "@opencode-ai/core/project/sql"
  15. import { Worktree } from "@opencode-ai/core/worktree"
  16. import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
  17. import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
  18. import { tmpdir } from "./fixture/tmpdir"
  19. import { testEffect } from "./lib/effect"
  20. const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
  21. const projectIt = testEffect(
  22. AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
  23. )
  24. function abs(input: string) {
  25. return AbsolutePath.make(input)
  26. }
  27. const gitWorktree = Worktree.StrategyID.make("git")
  28. async function initRepo(directory: string) {
  29. await $`git init`.cwd(directory).quiet()
  30. await $`git config core.fsmonitor false`.cwd(directory).quiet()
  31. await $`git config commit.gpgsign false`.cwd(directory).quiet()
  32. await $`git config user.email test@opencode.test`.cwd(directory).quiet()
  33. await $`git config user.name Test`.cwd(directory).quiet()
  34. await $`git commit --allow-empty -m root`.cwd(directory).quiet()
  35. }
  36. function setup() {
  37. return Effect.gen(function* () {
  38. const root = yield* Effect.acquireRelease(
  39. Effect.promise(() => tmpdir()),
  40. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  41. )
  42. yield* Effect.promise(() => initRepo(root.path))
  43. const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
  44. const projectID = Project.ID.make("worktree-project")
  45. const { db } = yield* Database.Service
  46. yield* db
  47. .insert(ProjectTable)
  48. .values({ id: projectID, worktree: sourceDirectory, sandboxes: [], time_created: 1, time_updated: 1 })
  49. .run()
  50. .pipe(Effect.orDie)
  51. yield* db
  52. .insert(WorktreeTable)
  53. .values({ project_id: projectID, directory: sourceDirectory })
  54. .run()
  55. .pipe(Effect.orDie)
  56. return { root, sourceDirectory, projectID, db }
  57. })
  58. }
  59. function stored(projectID: Project.ID) {
  60. return Database.Service.use(({ db }) =>
  61. db
  62. .select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
  63. .from(WorktreeTable)
  64. .where(eq(WorktreeTable.project_id, projectID))
  65. .all()
  66. .pipe(
  67. Effect.orDie,
  68. Effect.map((rows) => rows.toSorted((a, b) => a.directory.localeCompare(b.directory))),
  69. ),
  70. )
  71. }
  72. describe("Worktree", () => {
  73. projectIt.live("tracks canonical and linked worktrees when a project resolves", () =>
  74. Effect.gen(function* () {
  75. const root = yield* Effect.acquireRelease(
  76. Effect.promise(() => tmpdir()),
  77. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  78. )
  79. yield* Effect.promise(() => initRepo(root.path))
  80. const linked = `${root.path}-linked`
  81. yield* Effect.addFinalizer(() =>
  82. Effect.promise(() => fs.rm(linked, { recursive: true, force: true })).pipe(Effect.ignore),
  83. )
  84. yield* Effect.promise(() => $`git worktree add ${linked} -b linked-${Date.now()}`.cwd(root.path).quiet())
  85. const project = yield* Project.Service
  86. const resolved = yield* project.resolve(abs(linked))
  87. expect(yield* stored(resolved.id)).toEqual(
  88. [
  89. { directory: abs(yield* Effect.promise(() => fs.realpath(root.path))), strategy: null },
  90. { directory: abs(yield* Effect.promise(() => fs.realpath(linked))), strategy: "git" },
  91. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  92. )
  93. }),
  94. )
  95. it.effect("accepts arbitrary non-empty strategy ids", () =>
  96. Effect.sync(() => {
  97. expect(String(Worktree.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
  98. expect(() => Worktree.StrategyID.make(" acme/snapshot ")).toThrow()
  99. expect(() => Worktree.StrategyID.make(" ")).toThrow()
  100. }),
  101. )
  102. it.effect("reports unavailable strategy ids", () =>
  103. Effect.gen(function* () {
  104. const input = yield* setup()
  105. const worktree = yield* Worktree.Service
  106. const unavailable = Worktree.StrategyID.make("acme/missing")
  107. const error = yield* worktree
  108. .create({
  109. projectID: input.projectID,
  110. strategy: unavailable,
  111. from: input.sourceDirectory,
  112. directory: abs(`${input.root.path}-missing-strategy`),
  113. name: "worktree",
  114. })
  115. .pipe(Effect.flip)
  116. expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
  117. if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
  118. }),
  119. )
  120. it.live("requires a tracked project worktree", () =>
  121. Effect.gen(function* () {
  122. const input = yield* setup()
  123. yield* input.db
  124. .delete(WorktreeTable)
  125. .where(eq(WorktreeTable.project_id, input.projectID))
  126. .run()
  127. .pipe(Effect.orDie)
  128. const worktree = yield* Worktree.Service
  129. const error = yield* worktree
  130. .create({
  131. projectID: input.projectID,
  132. strategy: gitWorktree,
  133. from: input.sourceDirectory,
  134. directory: abs(`${input.root.path}-missing-source`),
  135. name: "worktree",
  136. })
  137. .pipe(Effect.flip)
  138. expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
  139. if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.directory).toBe(input.sourceDirectory)
  140. }),
  141. )
  142. it.live("creates and removes a git worktree directory", () =>
  143. Effect.gen(function* () {
  144. const input = yield* setup()
  145. const worktree = yield* Worktree.Service
  146. const bus = yield* Bus.Service
  147. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  148. const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
  149. const target = abs(path.join(parent, "worktree"))
  150. yield* Effect.addFinalizer(() =>
  151. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  152. )
  153. const fiber = yield* bus
  154. .subscribe(Worktree.Event.Updated)
  155. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  156. yield* Effect.yieldNow
  157. const created = yield* worktree.create({
  158. projectID: input.projectID,
  159. strategy: gitWorktree,
  160. directory: parent,
  161. name: "worktree",
  162. })
  163. expect(created.directory).toBe(target)
  164. expect(yield* stored(input.projectID)).toEqual(
  165. [
  166. { directory: input.sourceDirectory, strategy: null },
  167. { directory: created.directory, strategy: "git" },
  168. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  169. )
  170. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  171. yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
  172. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  173. expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
  174. }),
  175. )
  176. it.live("rejects a missing source directory", () =>
  177. Effect.gen(function* () {
  178. const input = yield* setup()
  179. const worktree = yield* Worktree.Service
  180. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  181. const error = yield* worktree
  182. .create({
  183. projectID: input.projectID,
  184. strategy: gitWorktree,
  185. from: abs(path.join(temp, "does-not-exist")),
  186. directory: abs(`${input.root.path}-missing-directory`),
  187. name: "worktree",
  188. })
  189. .pipe(Effect.flip)
  190. expect(error).toBeInstanceOf(WorktreeDirectory.DirectoryUnavailableError)
  191. }),
  192. )
  193. it.live("creates from another managed worktree", () =>
  194. Effect.gen(function* () {
  195. const input = yield* setup()
  196. const worktree = yield* Worktree.Service
  197. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  198. const sourceParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-source"))
  199. const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
  200. yield* Effect.addFinalizer(() =>
  201. Effect.all([
  202. Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
  203. Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
  204. ]).pipe(Effect.asVoid),
  205. )
  206. const source = yield* worktree.create({
  207. projectID: input.projectID,
  208. strategy: gitWorktree,
  209. from: input.sourceDirectory,
  210. directory: sourceParent,
  211. name: "source",
  212. })
  213. yield* input.db
  214. .delete(WorktreeTable)
  215. .where(and(eq(WorktreeTable.project_id, input.projectID), isNull(WorktreeTable.strategy)))
  216. .run()
  217. .pipe(Effect.orDie)
  218. const created = yield* worktree.create({
  219. projectID: input.projectID,
  220. strategy: gitWorktree,
  221. from: source.directory,
  222. directory: targetParent,
  223. name: "target",
  224. })
  225. expect(created.directory).toBe(abs(path.join(targetParent, "target")))
  226. yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
  227. yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
  228. }),
  229. )
  230. it.live("requires force to remove a dirty git worktree", () =>
  231. Effect.gen(function* () {
  232. const input = yield* setup()
  233. const worktree = yield* Worktree.Service
  234. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  235. const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
  236. yield* Effect.addFinalizer(() =>
  237. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  238. )
  239. const created = yield* worktree.create({
  240. projectID: input.projectID,
  241. strategy: gitWorktree,
  242. from: input.sourceDirectory,
  243. directory: parent,
  244. name: "worktree",
  245. })
  246. yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
  247. const error = yield* worktree
  248. .remove({ projectID: input.projectID, directory: created.directory, force: false })
  249. .pipe(Effect.flip)
  250. expect(error).toBeInstanceOf(Git.WorktreeError)
  251. if (error instanceof Git.WorktreeError) {
  252. expect(error.operation).toBe("remove")
  253. expect(error.forceRequired).toBe(true)
  254. }
  255. expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
  256. expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
  257. yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
  258. expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
  259. }),
  260. )
  261. it.live("preserves worktrees whose stored strategy is unavailable", () =>
  262. Effect.gen(function* () {
  263. const input = yield* setup()
  264. const worktree = yield* Worktree.Service
  265. const unavailable = abs(`${input.root.path}-worktree-unavailable`)
  266. yield* Effect.promise(() => fs.mkdir(unavailable))
  267. yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
  268. yield* input.db
  269. .insert(WorktreeTable)
  270. .values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
  271. .run()
  272. .pipe(Effect.orDie)
  273. const error = yield* worktree
  274. .remove({ projectID: input.projectID, directory: unavailable, force: false })
  275. .pipe(Effect.flip)
  276. expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
  277. expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
  278. }),
  279. )
  280. it.live("adds a numeric suffix when a worktree directory already exists", () =>
  281. Effect.gen(function* () {
  282. const input = yield* setup()
  283. const worktree = yield* Worktree.Service
  284. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  285. const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
  286. const target = abs(path.join(parent, "worktree-3"))
  287. yield* Effect.addFinalizer(() =>
  288. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  289. )
  290. yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
  291. yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
  292. const created = yield* worktree.create({
  293. projectID: input.projectID,
  294. strategy: gitWorktree,
  295. from: input.sourceDirectory,
  296. directory: parent,
  297. name: "worktree",
  298. })
  299. expect(created.directory).toBe(target)
  300. expect(
  301. yield* Effect.promise(() => fs.stat(path.join(parent, "worktree")).then((item) => item.isDirectory())),
  302. ).toBe(true)
  303. expect(
  304. yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
  305. ).toBe(true)
  306. yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
  307. }),
  308. )
  309. it.live("fails after ten worktree directory conflicts", () =>
  310. Effect.gen(function* () {
  311. const input = yield* setup()
  312. const worktree = yield* Worktree.Service
  313. const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
  314. const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
  315. yield* Effect.addFinalizer(() =>
  316. Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
  317. )
  318. yield* Effect.promise(() =>
  319. Promise.all(
  320. Array.from({ length: 10 }, (_, index) =>
  321. fs.mkdir(path.join(parent, index === 0 ? "worktree" : `worktree-${index + 1}`), { recursive: true }),
  322. ),
  323. ),
  324. )
  325. const error = yield* worktree
  326. .create({
  327. projectID: input.projectID,
  328. strategy: gitWorktree,
  329. from: input.sourceDirectory,
  330. directory: parent,
  331. name: "worktree",
  332. })
  333. .pipe(Effect.flip)
  334. expect(error).toBeInstanceOf(Worktree.DestinationExistsError)
  335. if (error instanceof Worktree.DestinationExistsError)
  336. expect(error.directory).toBe(abs(path.join(parent, "worktree-10")))
  337. }),
  338. )
  339. it.live("does not publish an event when refresh finds no directory changes", () =>
  340. Effect.gen(function* () {
  341. const input = yield* setup()
  342. const worktree = yield* Worktree.Service
  343. const bus = yield* Bus.Service
  344. const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
  345. Stream.take(1),
  346. Stream.runCollect,
  347. Effect.forkScoped,
  348. Effect.flatMap((fiber) =>
  349. Effect.gen(function* () {
  350. yield* Effect.yieldNow
  351. yield* worktree.refresh({ projectID: input.projectID })
  352. return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
  353. }),
  354. ),
  355. )
  356. expect(event._tag).toBe("None")
  357. }),
  358. )
  359. it.live("refresh discovers and prunes an externally managed git worktree", () =>
  360. Effect.gen(function* () {
  361. const input = yield* setup()
  362. const worktree = yield* Worktree.Service
  363. const bus = yield* Bus.Service
  364. const target = abs(`${input.root.path}-worktree-external`)
  365. yield* Effect.addFinalizer(() =>
  366. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  367. )
  368. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  369. yield* input.db
  370. .insert(WorktreeTable)
  371. .values({ project_id: input.projectID, directory: target })
  372. .run()
  373. .pipe(Effect.orDie)
  374. const fiber = yield* bus
  375. .subscribe(Worktree.Event.Updated)
  376. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  377. yield* Effect.yieldNow
  378. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  379. expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
  380. expect(yield* stored(input.projectID)).toEqual(
  381. [
  382. { directory: input.sourceDirectory, strategy: null },
  383. { directory: discovered, strategy: "git" },
  384. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  385. )
  386. expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
  387. yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
  388. expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
  389. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  390. }),
  391. )
  392. it.live("refresh ignores stale git worktree registrations", () =>
  393. Effect.gen(function* () {
  394. const input = yield* setup()
  395. const worktree = yield* Worktree.Service
  396. const stale = abs(`${input.root.path}-worktree-stale`)
  397. const target = abs(`${input.root.path}-worktree-after-stale`)
  398. yield* Effect.addFinalizer(() =>
  399. Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
  400. )
  401. yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
  402. yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
  403. yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
  404. yield* worktree.refresh({ projectID: input.projectID })
  405. const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
  406. expect(yield* stored(input.projectID)).toEqual(
  407. [
  408. { directory: input.sourceDirectory, strategy: null },
  409. { directory: discovered, strategy: "git" },
  410. ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
  411. )
  412. }),
  413. )
  414. it.live("refresh ignores existing directories that are no longer git checkouts", () =>
  415. Effect.gen(function* () {
  416. const input = yield* setup()
  417. yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
  418. const worktree = yield* Worktree.Service
  419. yield* worktree.refresh({ projectID: input.projectID })
  420. expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
  421. }),
  422. )
  423. it.live("refresh with no roots is a no-op", () =>
  424. Effect.gen(function* () {
  425. const worktree = yield* Worktree.Service
  426. expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
  427. updated: [],
  428. removed: [],
  429. })
  430. }),
  431. )
  432. it.live("refresh removes missing ordinary checkouts", () =>
  433. Effect.gen(function* () {
  434. const input = yield* setup()
  435. const missing = abs(`${input.root.path}-missing-checkout`)
  436. yield* input.db
  437. .insert(WorktreeTable)
  438. .values({ project_id: input.projectID, directory: missing })
  439. .run()
  440. .pipe(Effect.orDie)
  441. const worktree = yield* Worktree.Service
  442. expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
  443. expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
  444. }),
  445. )
  446. })