opencode/packages/core/test/repository-cache.test.ts
Kit Langton 36ac35a7c8
refactor(util): make layer graphs opaque and composable
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
2026-08-31 13:46:27 -04:00

242 lines
11 KiB
TypeScript

import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { Clock, Duration, Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { Database } from "@opencode-ai/core/database/database"
import { KV } from "@opencode-ai/core/kv"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { branch, commit, git, read, withRemote } from "./fixture/git"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
// Cold Git setup and cloning can exceed Bun's five-second default on Windows.
setDefaultTimeout(15_000)
describe("RepositoryCache", () => {
it.live("persists the daily throttle across cache recreation and serializes competing refreshes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const initial = yield* Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
return yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
expect(initial.status).toBe("cloned")
yield* Effect.promise(() => commit(fixture.source, "two\n", "advance main"))
yield* Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday, refreshedAt: yesterday })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
const results = yield* Effect.all(
[0, 1].map(() =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
return yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
{ concurrency: "unbounded" },
)
expect(results.map((result) => result.status).toSorted()).toEqual(["cached", "refreshed"])
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
// A missing checkout must be recreated even when the persisted timestamp is recent.
yield* Effect.promise(() => fs.rm(initial.localPath, { recursive: true }))
yield* Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cloned")
}).pipe(Effect.provide(cacheLayer(fixture.root)))
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
}),
),
)
it.live("throttles failed refresh attempts without marking them successful", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const key = `repository-cache:${initial.localPath}`
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* Effect.promise(() =>
fs.rename(path.join(fixture.root, "origin.git"), path.join(fixture.root, "offline.git")),
)
const error = yield* Effect.flip(cache.ensure({ reference: fixture.reference, refresh: "daily" }))
expect(error).toBeInstanceOf(RepositoryCache.FetchFailedError)
const stored = yield* kv.get(key)
const stamp = Schema.decodeUnknownSync(
Schema.Struct({ attemptedAt: Schema.Number, refreshedAt: Schema.Number }),
)(stored)
expect(stamp.attemptedAt).toBeGreaterThan(yesterday)
expect(stamp.refreshedAt).toBe(yesterday)
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* kv.get(key)).toEqual(stored)
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
yield* Effect.promise(async () => {
await fs.rename(path.join(fixture.root, "offline.git"), path.join(fixture.root, "origin.git"))
await commit(fixture.source, "two\n", "advance main")
})
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect(yield* kv.get(key)).not.toEqual(stored)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("refreshes existing untracked checkouts and keeps branch freshness independent", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
yield* Effect.promise(() => branch(fixture.source, "feature", "feature\n"))
const main = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const feature = yield* cache.ensure({ reference: fixture.reference, branch: "feature", refresh: "daily" })
yield* kv.remove(`repository-cache:${feature.localPath}`)
yield* Effect.promise(() => commit(fixture.source, "new feature\n", "advance feature"))
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(
(yield* cache.ensure({ reference: fixture.reference, branch: "feature", refresh: "daily" })).status,
).toBe("refreshed")
expect(yield* read(path.join(main.localPath, "README.md"))).toBe("one\n")
expect(yield* read(path.join(feature.localPath, "README.md"))).toBe("new feature\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
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 cache = yield* RepositoryCache.Service
const result = yield* cache.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("keeps branch checkouts isolated from branchless refreshes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
yield* Effect.promise(() => branch(fixture.source, "feature", "two\n"))
const cache = yield* RepositoryCache.Service
const featured = yield* cache.ensure({ reference: fixture.reference, branch: "feature" })
expect(featured.branch).toBe("feature")
expect(featured.localPath.endsWith("repo@feature")).toBe(true)
expect(yield* read(path.join(featured.localPath, "README.md"))).toBe("two\n")
const refreshed = yield* cache.ensure({ reference: fixture.reference, refresh: true })
expect(refreshed.localPath).not.toBe(featured.localPath)
expect(yield* read(path.join(refreshed.localPath, "README.md"))).toBe("one\n")
const cached = yield* cache.ensure({ reference: fixture.reference, branch: "feature" })
expect(cached.status).toBe("cached")
expect(yield* read(path.join(cached.localPath, "README.md"))).toBe("two\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("does not mistake an enclosing repository for the cache checkout", () =>
withRemote((fixture) =>
Effect.gen(function* () {
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("returns typed branch validation and clone failures", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
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) {
return AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node]), [
Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })),
Database.node.replace(Database.configured({ path: path.join(root, "cache.sqlite") })),
])
}
function exists(file: string) {
return Effect.promise(() =>
fs.stat(file).then(
() => true,
() => false,
),
)
}