Forráskód Böngészése

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

Shoubhit Dash 3 hónapja
szülő
commit
371ee321e0

+ 70 - 4
packages/core/src/git.ts

@@ -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,
+        ),
       )
 }
 

+ 264 - 0
packages/core/src/repository-cache.ts

@@ -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"

+ 207 - 0
packages/core/src/repository.ts

@@ -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"

+ 49 - 0
packages/core/test/fixture/git.ts

@@ -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 })
+}

+ 66 - 0
packages/core/test/git.test.ts

@@ -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")))
+}

+ 120 - 0
packages/core/test/repository-cache.test.ts

@@ -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))
+}

+ 63 - 0
packages/core/test/repository.test.ts

@@ -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)
+  })
+})