| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- import { describe, expect } from "bun:test"
- import { $ } from "bun"
- import fs from "fs/promises"
- import path from "path"
- import { Effect, Layer, Schema } from "effect"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { Database } from "@opencode-ai/core/database/database"
- import { ProjectV2 } from "@opencode-ai/core/project"
- import { ProjectTable } from "@opencode-ai/core/project/sql"
- import { AbsolutePath } from "@opencode-ai/core/schema"
- import { Hash } from "@opencode-ai/util/hash"
- import { tmpdir } from "./fixture/tmpdir"
- import { testEffect } from "./lib/effect"
- const it = testEffect(Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)))
- describe("ProjectV2.list", () => {
- it.effect("returns complete projects ordered by recent update", () =>
- Effect.gen(function* () {
- const db = (yield* Database.Service).db
- const project = yield* ProjectV2.Service
- yield* db
- .insert(ProjectTable)
- .values([
- {
- id: ProjectV2.ID.make("older"),
- worktree: abs("/older"),
- vcs: "git",
- name: "Older",
- icon_color: "#000000",
- commands: { start: "bun dev" },
- sandboxes: [abs("/older/sandbox")],
- time_created: 1,
- time_updated: 1,
- },
- {
- id: ProjectV2.ID.make("newer"),
- worktree: abs("/newer"),
- sandboxes: [],
- time_created: 2,
- time_updated: 2,
- time_initialized: 3,
- },
- ])
- .run()
- expect(yield* project.list()).toEqual([
- {
- id: ProjectV2.ID.make("newer"),
- worktree: abs("/newer"),
- time: { created: 2, updated: 2, initialized: 3 },
- sandboxes: [],
- },
- {
- id: ProjectV2.ID.make("older"),
- worktree: abs("/older"),
- vcs: "git",
- name: "Older",
- icon: { color: "#000000" },
- commands: { start: "bun dev" },
- time: { created: 1, updated: 1 },
- sandboxes: [abs("/older/sandbox")],
- },
- ])
- }),
- )
- })
- function remoteID(remote: string) {
- return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
- }
- function abs(value: string) {
- return AbsolutePath.make(value)
- }
- function real(value: string) {
- return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
- }
- async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
- await $`git init`.cwd(dir).quiet()
- await $`git config core.fsmonitor false`.cwd(dir).quiet()
- await $`git config commit.gpgsign false`.cwd(dir).quiet()
- await $`git config user.email test@opencode.test`.cwd(dir).quiet()
- await $`git config user.name Test`.cwd(dir).quiet()
- if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
- if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
- }
- async function rootCommit(dir: string) {
- return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
- }
- describe("ProjectV2.resolve", () => {
- it.live("returns global for non-git directory", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.id).toBe(ProjectV2.ID.make("global"))
- expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
- expect(result.previous).toBeUndefined()
- expect(result.vcs).toBeUndefined()
- }),
- )
- it.live("returns git global for repo with no commits and no remote", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.id).toBe(ProjectV2.ID.make("global"))
- expect(result.directory).toBe(yield* real(tmp.path))
- expect(result.previous).toBeUndefined()
- expect(result.vcs?.type).toBe("git")
- }),
- )
- it.live("falls back to root commit when origin is missing", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
- expect(result.directory).toBe(yield* real(tmp.path))
- expect(result.previous).toBeUndefined()
- expect(result.vcs?.type).toBe("git")
- }),
- )
- it.live("prefers normalized origin over root commit", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.id).toBe(remoteID("github.com/Acme/App"))
- expect(result.id).not.toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
- expect(result.directory).toBe(yield* real(tmp.path))
- expect(result.vcs?.type).toBe("git")
- }),
- )
- it.live("normalizes ssh and https remotes to the same id", () =>
- Effect.gen(function* () {
- const ssh = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- const https = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
- yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
- const project = yield* ProjectV2.Service
- const a = yield* project.resolve(abs(ssh.path))
- const b = yield* project.resolve(abs(https.path))
- expect(a.id).toBe(remoteID("github.com/owner/repo"))
- expect(b.id).toBe(a.id)
- }),
- )
- it.live("ignores file remotes and falls back to root commit", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
- }),
- )
- it.live("returns previous cached id from common dir", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
- yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
- expect(result.id).toBe(remoteID("github.com/owner/repo"))
- }),
- )
- it.live("does not write the cache while resolving", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
- const project = yield* ProjectV2.Service
- yield* project.resolve(abs(tmp.path))
- expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
- }),
- )
- it.live("resolves from nested directories to repo root", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
- yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
- expect(result.directory).toBe(yield* real(tmp.path))
- }),
- )
- const itHg = Bun.which("hg") ? it : { live: it.live.skip }
- itHg.live("detects mercurial repositories from nested directories", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(async () => {
- await $`hg init`.cwd(tmp.path).quiet()
- await Bun.write(path.join(tmp.path, "file.txt"), "one\n")
- await $`hg addremove -q`
- .cwd(tmp.path)
- .env({ ...process.env, HGPLAIN: "1" })
- .quiet()
- await $`hg commit -q -m initial -u test`
- .cwd(tmp.path)
- .env({ ...process.env, HGPLAIN: "1" })
- .quiet()
- await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
- })
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
- expect(result.vcs?.type).toBe("hg")
- expect(result.directory).toBe(abs(tmp.path))
- expect(result.id).not.toBe(ProjectV2.ID.make("global"))
- expect(result.previous).toBeUndefined()
- }),
- )
- it.live("prefers git when both git and mercurial metadata exist", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
- yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.vcs?.type).toBe("git")
- }),
- )
- it.live("returns global id for unreadable mercurial metadata", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(tmp.path))
- expect(result.vcs?.type).toBe("hg")
- expect(result.id).toBe(ProjectV2.ID.make("global"))
- }),
- )
- it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
- Effect.gen(function* () {
- const tmp = yield* Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- const worktree = `${tmp.path}-worktree`
- yield* Effect.addFinalizer(() =>
- Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
- )
- yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
- yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
- yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
- const project = yield* ProjectV2.Service
- const result = yield* project.resolve(abs(worktree))
- expect(result.directory).toBe(yield* real(worktree))
- expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
- expect(result.id).toBe(remoteID("github.com/owner/repo"))
- expect(result.vcs?.type).toBe("git")
- }),
- )
- })
|