Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Test User <test@test.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Qiping Li <liqiping1991@gmail.com> Co-authored-by: liqiping <liqiping@msh.team> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com> Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com> Co-authored-by: Daniel Polito <danielbpolito@gmail.com> Co-authored-by: opencode <noreply@opencode.ai> Co-authored-by: Devin R Leopold <devin.leopold@gmail.com> Co-authored-by: Zach Bruggeman <mail@bruggie.com> Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: David Siewert <david1gruppenplan@gmail.com> Co-authored-by: Andrei Dziahel <develop7@develop7.info> Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Matt Carey <mcarey@cloudflare.com>
259 lines
9.5 KiB
TypeScript
259 lines
9.5 KiB
TypeScript
/**
|
|
* Local tracking checkouts for remote Git references, one per remote and
|
|
* branch. Each checkout permanently tracks a single ref: the requested branch
|
|
* when the cache key has one, otherwise the remote default branch. Content
|
|
* follows "newest wins": refresh fetches and hard-resets, so readers may
|
|
* observe the checkout move underneath them.
|
|
*/
|
|
import path from "path"
|
|
import { Context, Effect, Layer, Schema } from "effect"
|
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
import { Git } from "./git"
|
|
import { Global } from "@opencode-ai/util/global"
|
|
import { Repository } from "./repository"
|
|
import { AbsolutePath } from "./schema"
|
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
|
import { EffectFlock } from "@opencode-ai/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) }),
|
|
})
|
|
})
|
|
|
|
const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFlock.Service | Global.Service> =
|
|
Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.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, input.branch)
|
|
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 existing = yield* git.repo.discover(AbsolutePath.make(localPath))
|
|
const origin = existing ? yield* git.remote.get(existing) : undefined
|
|
const originReference = origin ? Repository.parse(origin) : undefined
|
|
// Discovery walks upward, so an enclosing repository with a
|
|
// matching origin could masquerade as the cache entry; reuse
|
|
// requires the checkout to live exactly at the cache path.
|
|
const worktree = existing ? yield* fs.resolve(localPath) : undefined
|
|
const reuse = Boolean(
|
|
existing &&
|
|
existing.worktree === worktree &&
|
|
originReference &&
|
|
Repository.same(originReference, cloneTarget),
|
|
)
|
|
if (!reuse && (yield* fs.existsSafe(localPath))) {
|
|
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
|
|
}
|
|
|
|
const status = !reuse
|
|
? ("cloned" as const)
|
|
: input.refresh
|
|
? ("refreshed" as const)
|
|
: ("cached" as const)
|
|
|
|
if (status === "cloned") {
|
|
yield* git.repo
|
|
.clone({
|
|
remote: input.reference.remote,
|
|
directory: AbsolutePath.make(localPath),
|
|
branch: input.branch,
|
|
})
|
|
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
|
|
}
|
|
|
|
if (status === "refreshed") {
|
|
if (!existing)
|
|
return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
|
|
yield* git.sync
|
|
.fetchRemotes(existing)
|
|
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
|
|
|
if (input.branch) {
|
|
yield* git.sync
|
|
.fetchBranch(existing, { branch: input.branch })
|
|
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
|
}
|
|
|
|
// Checking out the tracked ref before resetting keeps the
|
|
// checkout self-healing even if it was left on another
|
|
// branch.
|
|
const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing))
|
|
if (branch) {
|
|
yield* git.sync
|
|
.checkoutRemoteBranch(existing, { branch })
|
|
.pipe(
|
|
Effect.mapError(
|
|
(error) => new CheckoutFailedError({ repository, branch, message: error.message }),
|
|
),
|
|
)
|
|
}
|
|
|
|
const target = branch ?? (yield* git.history.branch(existing))
|
|
yield* git.sync
|
|
.resetHard(existing, target ? `origin/${target}` : "HEAD")
|
|
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
|
|
}
|
|
|
|
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
|
|
|
|
return {
|
|
repository,
|
|
host: input.reference.host,
|
|
remote: input.reference.remote,
|
|
localPath,
|
|
status,
|
|
head: checkout ? yield* git.history.head(checkout) : undefined,
|
|
branch: checkout ? yield* git.history.branch(checkout) : undefined,
|
|
} satisfies Result
|
|
}),
|
|
`repository-cache:${localPath}`,
|
|
)
|
|
.pipe(
|
|
Effect.mapError((error) =>
|
|
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
|
|
),
|
|
)
|
|
}),
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const node = makeGlobalNode({
|
|
service: Service,
|
|
layer,
|
|
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
|
|
})
|
|
|
|
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) })),
|
|
)
|
|
}
|
|
|
|
export * as RepositoryCache from "./repository-cache"
|