feat(core): add managed repository cache (#30408)
This commit is contained in:
parent
a78adb1b09
commit
371ee321e0
7 changed files with 839 additions and 4 deletions
|
|
@ -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
packages/core/src/repository-cache.ts
Normal file
264
packages/core/src/repository-cache.ts
Normal 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"
|
||||
207
packages/core/src/repository.ts
Normal file
207
packages/core/src/repository.ts
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue