|
@@ -0,0 +1,198 @@
|
|
|
|
|
+import { afterEach, describe, expect } from "bun:test"
|
|
|
|
|
+import path from "path"
|
|
|
|
|
+import { pathToFileURL } from "node:url"
|
|
|
|
|
+import { Cause, Effect, Exit, Layer } from "effect"
|
|
|
|
|
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
|
|
|
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
|
|
|
+import { Global } from "@opencode-ai/core/global"
|
|
|
|
|
+import { Agent } from "../../src/agent/agent"
|
|
|
|
|
+import { Git } from "../../src/git"
|
|
|
|
|
+import { Instance } from "../../src/project/instance"
|
|
|
|
|
+import { MessageID, SessionID } from "../../src/session/schema"
|
|
|
|
|
+import { Truncate } from "../../src/tool"
|
|
|
|
|
+import { RepoCloneTool } from "../../src/tool/repo_clone"
|
|
|
|
|
+import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
|
|
|
|
+import { testEffect } from "../lib/effect"
|
|
|
|
|
+
|
|
|
|
|
+afterEach(async () => {
|
|
|
|
|
+ await Instance.disposeAll()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const ctx = {
|
|
|
|
|
+ sessionID: SessionID.make("ses_test"),
|
|
|
|
|
+ messageID: MessageID.make(""),
|
|
|
|
|
+ callID: "",
|
|
|
|
|
+ agent: "scout",
|
|
|
|
|
+ abort: AbortSignal.any([]),
|
|
|
|
|
+ messages: [],
|
|
|
|
|
+ metadata: () => Effect.void,
|
|
|
|
|
+ ask: () => Effect.void,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const it = testEffect(
|
|
|
|
|
+ Layer.mergeAll(
|
|
|
|
|
+ Agent.defaultLayer,
|
|
|
|
|
+ AppFileSystem.defaultLayer,
|
|
|
|
|
+ CrossSpawnSpawner.defaultLayer,
|
|
|
|
|
+ Git.defaultLayer,
|
|
|
|
|
+ Truncate.defaultLayer,
|
|
|
|
|
+ ),
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const init = Effect.fn("RepoCloneToolTest.init")(function* () {
|
|
|
|
|
+ const info = yield* RepoCloneTool
|
|
|
|
|
+ return yield* info.init()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) {
|
|
|
|
|
+ return yield* Effect.promise(async () => {
|
|
|
|
|
+ const proc = Bun.spawn(["git", ...args], {
|
|
|
|
|
+ cwd,
|
|
|
|
|
+ stdout: "pipe",
|
|
|
|
|
+ stderr: "pipe",
|
|
|
|
|
+ })
|
|
|
|
|
+ const [stdout, stderr, code] = await Promise.all([
|
|
|
|
|
+ new Response(proc.stdout).text(),
|
|
|
|
|
+ new Response(proc.stderr).text(),
|
|
|
|
|
+ proc.exited,
|
|
|
|
|
+ ])
|
|
|
|
|
+ if (code !== 0) {
|
|
|
|
|
+ throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
|
|
|
|
+ }
|
|
|
|
|
+ return stdout.trim()
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
|
|
|
|
+ Effect.acquireUseRelease(
|
|
|
|
|
+ Effect.sync(() => {
|
|
|
|
|
+ const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
|
|
|
|
+ process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
|
|
|
|
|
+ return previous
|
|
|
|
|
+ }),
|
|
|
|
|
+ () => self,
|
|
|
|
|
+ (previous) =>
|
|
|
|
|
+ Effect.sync(() => {
|
|
|
|
|
+ if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
|
|
|
|
+ else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+describe("tool.repo_clone", () => {
|
|
|
|
|
+ it.live("clones a repo into the managed cache and reuses it on subsequent calls", () =>
|
|
|
|
|
+ provideTmpdirInstance((_dir) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
|
|
+ const source = yield* tmpdirScoped({ git: true })
|
|
|
|
|
+ const remoteRoot = yield* tmpdirScoped()
|
|
|
|
|
+ const remoteDir = path.join(remoteRoot, "owner")
|
|
|
|
|
+ const remoteRepo = path.join(remoteDir, "repo.git")
|
|
|
|
|
+
|
|
|
|
|
+ yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
|
|
|
|
+ yield* git(source, ["add", "."])
|
|
|
|
|
+ yield* git(source, ["commit", "-m", "add readme"])
|
|
|
|
|
+ yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
|
|
|
|
+ yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
|
|
|
|
+
|
|
|
|
|
+ const tool = yield* init()
|
|
|
|
|
+ const cloned = yield* githubBase(
|
|
|
|
|
+ `file://${remoteRoot}/`,
|
|
|
|
|
+ tool.execute({ repository: "owner/repo" }, ctx),
|
|
|
|
|
+ )
|
|
|
|
|
+ const cached = yield* githubBase(
|
|
|
|
|
+ `file://${remoteRoot}/`,
|
|
|
|
|
+ tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ expect(cloned.metadata.status).toBe("cloned")
|
|
|
|
|
+ expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
|
|
|
|
|
+ expect(cached.metadata.status).toBe("cached")
|
|
|
|
|
+ expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n")
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ it.live("refresh updates an existing cached clone", () =>
|
|
|
|
|
+ provideTmpdirInstance((_dir) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
|
|
+ const source = yield* tmpdirScoped({ git: true })
|
|
|
|
|
+ const remoteRoot = yield* tmpdirScoped()
|
|
|
|
|
+ const remoteDir = path.join(remoteRoot, "owner")
|
|
|
|
|
+ const remoteRepo = path.join(remoteDir, "repo.git")
|
|
|
|
|
+
|
|
|
|
|
+ yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
|
|
|
|
+ yield* git(source, ["add", "."])
|
|
|
|
|
+ yield* git(source, ["commit", "-m", "add readme"])
|
|
|
|
|
+ yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
|
|
|
|
+ yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
|
|
|
|
+
|
|
|
|
|
+ const branch = yield* git(source, ["branch", "--show-current"])
|
|
|
|
|
+ yield* git(source, ["remote", "add", "origin", remoteRepo])
|
|
|
|
|
+ yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`])
|
|
|
|
|
+
|
|
|
|
|
+ const tool = yield* init()
|
|
|
|
|
+ const first = yield* githubBase(
|
|
|
|
|
+ `file://${remoteRoot}/`,
|
|
|
|
|
+ tool.execute({ repository: "owner/repo" }, ctx),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
|
|
|
|
|
+ yield* git(source, ["add", "."])
|
|
|
|
|
+ yield* git(source, ["commit", "-m", "update readme"])
|
|
|
|
|
+ yield* git(source, ["push", "origin", `${branch}:${branch}`])
|
|
|
|
|
+
|
|
|
|
|
+ const refreshed = yield* githubBase(
|
|
|
|
|
+ `file://${remoteRoot}/`,
|
|
|
|
|
+ tool.execute({ repository: "owner/repo", refresh: true }, ctx),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ expect(first.metadata.status).toBe("cloned")
|
|
|
|
|
+ expect(refreshed.metadata.status).toBe("refreshed")
|
|
|
|
|
+ expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n")
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ it.live("rejects invalid repository inputs", () =>
|
|
|
|
|
+ provideTmpdirInstance((_dir) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const tool = yield* init()
|
|
|
|
|
+ const result = yield* tool.execute({ repository: "not-a-repo" }, ctx).pipe(Effect.exit)
|
|
|
|
|
+
|
|
|
|
|
+ expect(Exit.isFailure(result)).toBe(true)
|
|
|
|
|
+ if (Exit.isFailure(result)) {
|
|
|
|
|
+ const error = Cause.squash(result.cause)
|
|
|
|
|
+ expect(error instanceof Error ? error.message : String(error)).toContain("git URL")
|
|
|
|
|
+ }
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ it.live("clones generic git URLs into the managed cache", () =>
|
|
|
|
|
+ provideTmpdirInstance((_dir) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
|
|
+ const source = yield* tmpdirScoped({ git: true })
|
|
|
|
|
+ const remoteRoot = yield* tmpdirScoped()
|
|
|
|
|
+ const remoteDir = path.join(remoteRoot, "forge")
|
|
|
|
|
+ const remoteRepo = path.join(remoteDir, "repo.git")
|
|
|
|
|
+
|
|
|
|
|
+ yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
|
|
|
|
+ yield* git(source, ["add", "."])
|
|
|
|
|
+ yield* git(source, ["commit", "-m", "add readme"])
|
|
|
|
|
+ yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
|
|
|
|
+ yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
|
|
|
|
+
|
|
|
|
|
+ const tool = yield* init()
|
|
|
|
|
+ const result = yield* tool.execute({ repository: pathToFileURL(remoteRepo).href }, ctx)
|
|
|
|
|
+
|
|
|
|
|
+ expect(result.metadata.status).toBe("cloned")
|
|
|
|
|
+ expect(result.metadata.host).toBe("file")
|
|
|
|
|
+ expect(result.metadata.localPath.startsWith(path.join(Global.Path.repos, "file"))).toBe(true)
|
|
|
|
|
+ expect(result.metadata.localPath.endsWith(path.join("forge", "repo"))).toBe(true)
|
|
|
|
|
+ expect(yield* fs.readFileString(path.join(result.metadata.localPath, "README.md"))).toBe("v1\n")
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+})
|