feat(core): add managed repository cache (#30408)

This commit is contained in:
Shoubhit Dash 2026-06-02 19:10:09 +05:30 committed by GitHub
parent a78adb1b09
commit 371ee321e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 839 additions and 4 deletions

View File

@ -30,6 +30,15 @@ export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
readonly roots: (repo: Repo) => Effect.Effect<string[]>
readonly origin: (directory: string) => Effect.Effect<string | undefined>
readonly head: (directory: string) => Effect.Effect<string | undefined>
readonly branch: (directory: string) => Effect.Effect<string | undefined>
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
readonly clone: (input: { remote: string; target: string; branch?: string; depth?: number }) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly fetch: (directory: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
@ -75,7 +84,57 @@ export const layer = Layer.effect(
.toSorted()
})
return Service.of({ find, remote, roots })
const origin = Effect.fn("Git.origin")(function* (directory: string) {
const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const head = Effect.fn("Git.head")(function* (directory: string) {
const result = yield* run(directory, proc)(["rev-parse", "HEAD"])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const branch = Effect.fn("Git.branch")(function* (directory: string) {
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) {
const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"])
if (result.exitCode !== 0) return undefined
return result.text.trim().replace(/^refs\/remotes\//, "") || undefined
})
const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) =>
execute(path.dirname(input.target), proc)([
"clone",
"--depth",
String(input.depth ?? 100),
...(input.branch ? ["--branch", input.branch] : []),
"--",
input.remote,
input.target,
]),
)
const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"]))
const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) =>
execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]),
)
const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) =>
execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]),
)
const reset = Effect.fn("Git.reset")((directory: string, target: string) =>
execute(directory, proc)(["reset", "--hard", target]),
)
return Service.of({ find, remote, roots, origin, head, branch, remoteHead, clone, fetch, fetchBranch, checkout, reset })
}),
)
@ -84,12 +143,17 @@ export const defaultLayer = layer.pipe(
Layer.provide(AppProcess.defaultLayer),
)
interface Result {
export interface Result {
readonly exitCode: number
readonly text: string
readonly stderr: string
}
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) => execute(cwd, proc)(args).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, text: "", stderr: "" })))
}
function execute(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
@ -100,8 +164,10 @@ function run(cwd: string, proc: AppProcess.Interface) {
}),
)
.pipe(
Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result),
Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
Effect.map(
(result) =>
({ exitCode: result.exitCode, text: result.stdout.toString("utf8"), stderr: result.stderr.toString("utf8") }) satisfies Result,
),
)
}

View File

@ -0,0 +1,264 @@
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
import { Git } from "./git"
import { Global } from "./global"
import { Repository } from "./repository"
import { EffectFlock } from "./util/effect-flock"
export type Result = {
readonly repository: string
readonly host: string
readonly remote: string
readonly localPath: string
readonly status: "cached" | "cloned" | "refreshed"
readonly head?: string
readonly branch?: string
}
export type EnsureInput = {
readonly reference: Repository.RemoteReference
readonly refresh?: boolean
readonly branch?: string
}
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
"RepositoryCacheInvalidRepositoryError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()("RepositoryCacheInvalidBranchError", {
branch: Schema.String,
message: Schema.String,
}) {}
export class CloneFailedError extends Schema.TaggedErrorClass<CloneFailedError>()("RepositoryCacheCloneFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class FetchFailedError extends Schema.TaggedErrorClass<FetchFailedError>()("RepositoryCacheFetchFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class CheckoutFailedError extends Schema.TaggedErrorClass<CheckoutFailedError>()(
"RepositoryCacheCheckoutFailedError",
{
repository: Schema.String,
branch: Schema.String,
message: Schema.String,
},
) {}
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("RepositoryCacheResetFailedError", {
repository: Schema.String,
message: Schema.String,
}) {}
export class LockFailedError extends Schema.TaggedErrorClass<LockFailedError>()("RepositoryCacheLockFailedError", {
localPath: Schema.String,
message: Schema.String,
}) {}
export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationError>()("RepositoryCacheOperationError", {
operation: Schema.String,
path: Schema.String,
message: Schema.String,
}) {}
export type Error =
| InvalidRepositoryError
| InvalidBranchError
| CloneFailedError
| FetchFailedError
| CheckoutFailedError
| ResetFailedError
| LockFailedError
| CacheOperationError
export interface Interface {
readonly ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidRepositoryError ||
error instanceof InvalidBranchError ||
error instanceof CloneFailedError ||
error instanceof FetchFailedError ||
error instanceof CheckoutFailedError ||
error instanceof ResetFailedError ||
error instanceof LockFailedError ||
error instanceof CacheOperationError
)
}
export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
return yield* Effect.try({
try: () => Repository.parseRemote(repository),
catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
})
})
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
return yield* Effect.try({
try: () => Repository.validateBranch(branch),
catch: (error) => new InvalidBranchError({ branch, message: errorMessage(error) }),
})
})
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Git.Service | EffectFlock.Service | Global.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const flock = yield* EffectFlock.Service
const global = yield* Global.Service
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const localPath = Repository.cachePath(global.repos, input.reference)
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
return yield* flock
.withLock(
Effect.gen(function* () {
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
const exists = yield* fs.existsSafe(localPath)
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
if (exists && !reuse) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
branchMatches: input.branch ? currentBranch === input.branch : undefined,
})
if (status === "cloned") {
const result = yield* git.clone({ remote: input.reference.remote, target: localPath, branch: input.branch }).pipe(
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
)
if (result.exitCode !== 0) {
return yield* new CloneFailedError({ repository, message: resultMessage(result, `Failed to clone ${repository}`) })
}
}
if (status === "refreshed") {
const fetch = yield* git.fetch(localPath).pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetch.exitCode !== 0) {
return yield* new FetchFailedError({ repository, message: resultMessage(fetch, `Failed to refresh ${repository}`) })
}
if (input.branch) {
const requestedBranch = input.branch
const fetchBranch = yield* git.fetchBranch(localPath, requestedBranch).pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetchBranch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
})
}
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
Effect.mapError((error) =>
new CheckoutFailedError({ repository, branch: requestedBranch, message: errorMessage(error) }),
),
)
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
repository,
branch: requestedBranch,
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
})
}
}
const reset = yield* git.reset(localPath, yield* resetTarget(git, localPath, input.branch)).pipe(
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
)
if (reset.exitCode !== 0) {
return yield* new ResetFailedError({ repository, message: resultMessage(reset, `Failed to reset ${repository}`) })
}
}
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: yield* git.head(localPath),
branch: yield* git.branch(localPath),
} satisfies Result
}),
`repository-cache:${localPath}`,
)
.pipe(
Effect.mapError((error) =>
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
),
)
}),
})
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Global.defaultLayer),
)
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false || input.refresh) return "refreshed" as const
return "cached" as const
}
function errorMessage(error: unknown) {
return error instanceof globalThis.Error ? error.message : String(error)
}
function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: string, target: string) {
return effect.pipe(Effect.mapError((error) => new CacheOperationError({ operation, path: target, message: errorMessage(error) })))
}
const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) {
if (requestedBranch) return `origin/${requestedBranch}`
const remoteHead = yield* git.remoteHead(cwd)
if (remoteHead) return remoteHead
const currentBranch = yield* git.branch(cwd)
if (currentBranch) return `origin/${currentBranch}`
return "HEAD"
})
function resultMessage(result: Git.Result, fallback: string) {
return result.stderr.trim() || result.text.trim() || fallback
}
export * as RepositoryCache from "./repository-cache"

View File

@ -0,0 +1,207 @@
import path from "path"
import { fileURLToPath } from "url"
import { Schema } from "effect"
type BaseReference = {
readonly host: string
readonly path: string
readonly segments: string[]
readonly owner?: string
readonly repo: string
readonly remote: string
readonly label: string
}
export type RemoteReference = BaseReference & {
readonly protocol?: string
}
export type FileReference = BaseReference & {
readonly host: "file"
readonly protocol: "file:"
}
export type Reference = RemoteReference | FileReference
export class InvalidReferenceError extends Schema.TaggedErrorClass<InvalidReferenceError>()(
"RepositoryInvalidReferenceError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class UnsupportedLocalRepositoryError extends Schema.TaggedErrorClass<UnsupportedLocalRepositoryError>()(
"RepositoryUnsupportedLocalRepositoryError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()("RepositoryInvalidBranchError", {
branch: Schema.String,
message: Schema.String,
}) {}
export type Error = InvalidReferenceError | UnsupportedLocalRepositoryError | InvalidBranchError
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidReferenceError ||
error instanceof UnsupportedLocalRepositoryError ||
error instanceof InvalidBranchError
)
}
export function parse(input: string): Reference | undefined {
const cleaned = normalizeInput(input)
if (!cleaned) return
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
if (githubPrefixed) return buildRemote({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
if (!cleaned.includes("://")) {
const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
if (scp) return buildRemote({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
const direct = parts(cleaned)
if (direct.length >= 2 && hostLike(direct[0])) return buildRemote({ host: direct[0], segments: direct.slice(1) })
if (direct.length === 2) return buildRemote({ host: "github.com", segments: direct })
}
try {
const url = new URL(cleaned)
if (url.protocol === "file:") return buildFile({ url, remote: cleaned })
const segments = parts(url.pathname)
return buildRemote({
host: url.host,
segments,
remote: url.host === "github.com" ? githubRemote(segments.join("/")) : cleaned,
protocol: url.protocol,
})
} catch {
return
}
}
export function parseRemote(input: string): RemoteReference {
const reference = parse(input)
if (!reference) {
throw new InvalidReferenceError({
repository: input,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
})
}
if (!isRemote(reference)) {
throw new UnsupportedLocalRepositoryError({
repository: input,
message: "Local file repositories are not supported",
})
}
return reference
}
export function validateBranch(branch: string): void {
if (/^[A-Za-z0-9/_.-]+$/.test(branch) && !branch.startsWith("-") && !branch.includes("..")) return
throw new InvalidBranchError({
branch,
message: "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
})
}
export function isFile(reference: Reference): reference is FileReference {
return reference.protocol === "file:"
}
export function isRemote(reference: Reference): reference is RemoteReference {
return !isFile(reference)
}
export function cachePath(root: string, reference: Reference): string {
return path.join(root, ...reference.host.split(":"), ...reference.segments)
}
export function cacheIdentity(reference: Reference): string {
return `${reference.host}/${reference.path}`
}
export function same(left: Reference, right: Reference): boolean {
return cacheIdentity(left) === cacheIdentity(right)
}
function normalizeInput(input: string) {
return input
.trim()
.replace(/^git\+/, "")
.replace(/#.*$/, "")
.replace(/\/+$/, "")
}
function trimGitSuffix(input: string) {
return input.replace(/\.git$/, "")
}
function parts(input: string) {
return input
.split("/")
.map((item) => trimGitSuffix(item.trim()))
.filter(Boolean)
}
function safeHost(input: string) {
return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input)
}
function safeSegment(input: string) {
return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input)
}
function hostLike(input: string) {
return input.includes(".") || input.includes(":") || input === "localhost"
}
function withSlash(input: string) {
return input.endsWith("/") ? input : `${input}/`
}
function githubRemote(pathname: string) {
const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
if (!base) return `https://github.com/${pathname}.git`
return new URL(`${pathname}.git`, withSlash(base)).href
}
function buildRemote(input: { host: string; segments: string[]; remote?: string; protocol?: string }) {
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return
const repositoryPath = segments.join("/")
const host = input.host.toLowerCase()
return {
host,
path: repositoryPath,
segments,
owner: segments.length === 2 ? segments[0] : undefined,
repo: segments[segments.length - 1],
remote: input.remote ?? (host === "github.com" ? githubRemote(repositoryPath) : `https://${host}/${repositoryPath}.git`),
label: host === "github.com" && segments.length === 2 ? repositoryPath : `${host}/${repositoryPath}`,
protocol: input.protocol,
} satisfies RemoteReference
}
function buildFile(input: { url: URL; remote: string }) {
const filePath = path.normalize(fileURLToPath(input.url))
const segments = filePath.split(/[\\/]+/).filter(Boolean)
if (!segments.length) return
return {
host: "file",
path: filePath,
segments: segments.map((segment) => segment.replace(/:$/, "")),
owner: undefined,
repo: trimGitSuffix(segments[segments.length - 1]),
remote: input.remote,
label: filePath,
protocol: "file:",
} satisfies FileReference
}
export * as Repository from "./repository"

View File

@ -0,0 +1,49 @@
import { execFile } from "child_process"
import fs from "fs/promises"
import path from "path"
import { promisify } from "util"
import { pathToFileURL } from "url"
import { Repository } from "@opencode-ai/core/repository"
const exec = promisify(execFile)
export async function gitRemote(root: string) {
const origin = path.join(root, "origin.git")
const source = path.join(root, "source")
await git(root, "init", "--bare", origin)
await git(root, "init", source)
await git(source, "config", "user.email", "test@example.com")
await git(source, "config", "user.name", "Test")
await fs.writeFile(path.join(source, "README.md"), "one\n")
await git(source, "add", "README.md")
await git(source, "commit", "-m", "initial")
await git(source, "branch", "-M", "main")
await git(source, "remote", "add", "origin", pathToFileURL(origin).href)
await git(source, "push", "-u", "origin", "main")
await git(root, "--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/main")
return {
root,
source,
remote: pathToFileURL(origin).href,
reference: { ...Repository.parseRemote("owner/repo"), remote: pathToFileURL(origin).href },
}
}
export async function commit(source: string, content: string, message: string) {
await fs.writeFile(path.join(source, "README.md"), content)
await git(source, "add", "README.md")
await git(source, "commit", "-m", message)
await git(source, "push")
}
export async function branch(source: string, name: string, content: string) {
await git(source, "checkout", "-b", name)
await fs.writeFile(path.join(source, "README.md"), content)
await git(source, "add", "README.md")
await git(source, "commit", "-m", name)
await git(source, "push", "-u", "origin", name)
}
export async function git(cwd: string, ...args: string[]) {
await exec("git", args, { cwd })
}

View File

@ -0,0 +1,66 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Git } from "@opencode-ai/core/git"
import { branch, commit, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Git.defaultLayer)
describe("Git", () => {
it.live("clones a remote and reads checkout metadata", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const git = yield* Git.Service
const target = path.join(fixture.root, "checkout")
const result = yield* git.clone({ remote: fixture.remote, target })
expect(result.exitCode).toBe(0)
expect(yield* git.origin(target)).toBe(fixture.remote)
expect(yield* git.head(target)).toBeString()
expect(yield* git.branch(target)).toBe("main")
expect(yield* git.remoteHead(target)).toBe("origin/main")
expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
}),
),
)
it.live("fetches, checks out, and resets remote changes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const git = yield* Git.Service
const target = path.join(fixture.root, "checkout")
yield* git.clone({ remote: fixture.remote, target })
yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
expect((yield* git.fetch(target)).exitCode).toBe(0)
expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0)
expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
expect(yield* git.branch(target)).toBe("feature/docs")
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
}),
),
)
})
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}

View File

@ -0,0 +1,120 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Git } from "@opencode-ai/core/git"
import { Global } from "@opencode-ai/core/global"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { git, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("RepositoryCache", () => {
it.live("replaces a stale cache directory before cloning", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const localPath = Repository.cachePath(path.join(fixture.root, "repos"), fixture.reference)
yield* Effect.promise(async () => {
await fs.mkdir(localPath, { recursive: true })
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
})
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
expect(yield* read(path.join(localPath, "README.md"))).toBe("one\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("serializes concurrent materialization for the same checkout", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const results = yield* Effect.all(
[cache.ensure({ reference: fixture.reference }), cache.ensure({ reference: fixture.reference })],
{ concurrency: "unbounded" },
)
expect(results.map((result) => result.status).toSorted()).toEqual(["cached", "cloned"])
expect(results[0].localPath).toBe(results[1].localPath)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("replaces an existing checkout whose origin does not match", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const initial = yield* cache.ensure({ reference: fixture.reference })
yield* Effect.promise(async () => {
await git(initial.localPath, "config", "remote.origin.url", "https://github.com/other/repo.git")
await fs.writeFile(path.join(initial.localPath, "stale.txt"), "stale")
})
const replaced = yield* cache.ensure({ reference: fixture.reference })
expect(replaced.status).toBe("cloned")
expect(yield* exists(path.join(replaced.localPath, "stale.txt"))).toBe(false)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("returns typed validation and clone failures", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
const cloneFailure = yield* Effect.flip(
cache.ensure({
reference: { ...fixture.reference, remote: pathToFileURL(path.join(fixture.root, "missing.git")).href },
}),
)
expect(cloneFailure).toBeInstanceOf(RepositoryCache.CloneFailedError)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
})
function cacheLayer(root: string) {
const dependencies = Layer.mergeAll(
Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
AppFileSystem.defaultLayer,
)
return RepositoryCache.layer.pipe(
Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))),
Layer.provide(Git.defaultLayer),
Layer.provide(dependencies),
)
}
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
function exists(file: string) {
return Effect.promise(() => fs.stat(file).then(() => true, () => false))
}

View File

@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { Repository } from "@opencode-ai/core/repository"
describe("Repository", () => {
test("parses github shorthand and builds an explicit-root cache path", () => {
const reference = Repository.parseRemote("owner/repo")
expect(reference).toMatchObject({
host: "github.com",
path: "owner/repo",
segments: ["owner", "repo"],
owner: "owner",
repo: "repo",
remote: "https://github.com/owner/repo.git",
label: "owner/repo",
})
expect(Repository.cachePath("/cache", reference)).toBe(path.join("/cache", "github.com", "owner", "repo"))
expect(Repository.cacheIdentity(reference)).toBe("github.com/owner/repo")
})
test("parses host path and scp remote references", () => {
expect(Repository.parseRemote("gitlab.com/group/repo")).toMatchObject({
host: "gitlab.com",
path: "group/repo",
remote: "https://gitlab.com/group/repo.git",
label: "gitlab.com/group/repo",
})
expect(Repository.parseRemote("git@github.com:owner/repo.git")).toMatchObject({
host: "github.com",
path: "owner/repo",
remote: "git@github.com:owner/repo.git",
label: "owner/repo",
})
})
test("keeps local file repositories distinct from remote repositories", () => {
const localPath = path.resolve("repo.git")
const reference = Repository.parse(pathToFileURL(localPath).href)
expect(reference).toMatchObject({ host: "file", protocol: "file:", label: localPath })
expect(reference && Repository.isFile(reference)).toBe(true)
expect(reference && Repository.isRemote(reference)).toBe(false)
expect(() => Repository.parseRemote(pathToFileURL(localPath).href)).toThrow(Repository.UnsupportedLocalRepositoryError)
})
test("rejects unsafe remote references and branches with typed errors", () => {
expect(() => Repository.parseRemote("not-a-repo")).toThrow(Repository.InvalidReferenceError)
expect(() => Repository.parseRemote("git@github.com:../../../etc/passwd")).toThrow(Repository.InvalidReferenceError)
expect(() => Repository.validateBranch("feature/docs.v1")).not.toThrow()
expect(() => Repository.validateBranch("-bad")).toThrow(Repository.InvalidBranchError)
expect(() => Repository.validateBranch("bad..branch")).toThrow(Repository.InvalidBranchError)
expect(() => Repository.validateBranch("bad branch")).toThrow(Repository.InvalidBranchError)
})
test("compares cache identity independent of input spelling", () => {
const shorthand = Repository.parseRemote("owner/repo")
expect(Repository.same(shorthand, Repository.parseRemote("https://github.com/owner/repo.git"))).toBe(true)
expect(Repository.same(shorthand, Repository.parseRemote("github.com/owner/repo"))).toBe(true)
})
})