diff --git a/packages/core/src/project-reference.ts b/packages/core/src/project-reference.ts index ac7eb6d8c5..7e1fac7931 100644 --- a/packages/core/src/project-reference.ts +++ b/packages/core/src/project-reference.ts @@ -73,7 +73,7 @@ export const layer = Layer.effect( references: ConfigReference.normalize( Object.assign({}, ...(yield* config.get()).map((document) => document.info.references ?? {})), ), - directory: location.project.directory, + directory: location.vcs ? location.project.directory : location.directory, home: global.home, repos: global.repos, }) diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 00b3ffbd13..9313df088a 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -2,11 +2,14 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" -export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { +export function location( + ref: Location.Ref, + input: { projectID?: Project.ID; projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}, +) { return { directory: ref.directory, workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory }, + project: { id: input.projectID ?? Project.ID.global, directory: input.projectDirectory ?? ref.directory }, vcs: input.vcs, } satisfies Location.Interface } diff --git a/packages/core/test/project-reference.test.ts b/packages/core/test/project-reference.test.ts index a9c25e5138..afd09f0e41 100644 --- a/packages/core/test/project-reference.test.ts +++ b/packages/core/test/project-reference.test.ts @@ -8,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" import { ProjectReference } from "@opencode-ai/core/project-reference" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" @@ -86,6 +87,64 @@ describe("ProjectReference", () => { }), ) + it.live("resolves local references from the opened directory for global locations", () => + withoutReferences( + withTmp((tmp) => + withReferences( + Effect.gen(function* () { + const references = yield* ProjectReference.Service + expect(yield* references.get("docs")).toMatchObject({ + name: "docs", + kind: "local", + path: path.join(tmp.path, "opened", "docs"), + }) + }).pipe( + Effect.provide( + testLayer({ + directory: path.join(tmp.path, "opened"), + project: "/", + projectID: Project.ID.global, + repos: path.join(tmp.path, "repos"), + documents: [document({ docs: "./docs" })], + ensure: () => Effect.die("unexpected ensure"), + }), + ), + ), + ), + ), + ), + ) + + it.live("resolves local references from the project root for global-id Git locations", () => + withoutReferences( + withTmp((tmp) => { + const project = path.join(tmp.path, "project") + return withReferences( + Effect.gen(function* () { + const references = yield* ProjectReference.Service + expect(yield* references.get("docs")).toMatchObject({ + name: "docs", + kind: "local", + path: path.join(project, "docs"), + }) + }).pipe( + Effect.provide( + testLayer({ + directory: path.join(project, "nested"), + project, + projectID: Project.ID.global, + vcs: { type: "git", store: AbsolutePath.make(path.join(project, ".git")) }, + repos: path.join(tmp.path, "repos"), + documents: [document({ docs: "./docs" })], + ensure: () => Effect.die("unexpected ensure"), + }), + ), + ), + ) + }), + ), + ) + it.live("merges config aliases and exposes mention and managed-path operations", () => withoutReferences( withTmp((tmp) => { @@ -139,6 +198,7 @@ describe("ProjectReference", () => { testLayer({ directory: nested, project, + vcs: { type: "git", store: AbsolutePath.make(path.join(project, ".git")) }, repos, documents: [ document({ docs: { path: "./old-docs" }, sdk: "owner/old" }), @@ -236,6 +296,8 @@ function result( function testLayer(input: { directory: string project: string + projectID?: Project.ID + vcs?: Project.Vcs repos: string documents: Config.Loaded[] ensure: RepositoryCache.Interface["ensure"] @@ -250,7 +312,11 @@ function testLayer(input: { Location.Service.of( location( { directory: AbsolutePath.make(input.directory) }, - { projectDirectory: AbsolutePath.make(input.project) }, + { + projectID: input.projectID ?? Project.ID.make("project"), + projectDirectory: AbsolutePath.make(input.project), + vcs: input.vcs, + }, ), ), ), diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index d30cf6252b..a859e5066f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -18,8 +18,9 @@ import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" -import { Reference } from "@/reference/reference" -import { ConfigReference } from "@/config/reference" +import { Reference } from "@/reference" +import { ConfigReference } from "@opencode-ai/core/config/reference" +import { Flag } from "@opencode-ai/core/flag/flag" import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display" function removeLineRange(input: string) { @@ -329,11 +330,13 @@ export function Autocomplete(props: { } const references = createMemo(() => - Reference.resolveAll({ - references: ConfigReference.normalize(sync.data.config.reference ?? {}), - directory: sync.path.directory || process.cwd(), - worktree: sync.path.worktree || sync.path.directory || process.cwd(), - }), + Flag.OPENCODE_EXPERIMENTAL_REFERENCES + ? Reference.resolveAll({ + references: ConfigReference.normalize(sync.data.config.reference ?? {}), + directory: sync.path.directory || process.cwd(), + worktree: sync.path.worktree || sync.path.directory || process.cwd(), + }) + : [], ) const referenceSearch = createMemo(() => { diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts deleted file mode 100644 index 163d4a1c2c..0000000000 --- a/packages/opencode/src/config/reference.ts +++ /dev/null @@ -1,48 +0,0 @@ -export * as ConfigReference from "./reference" - -import { ConfigReferenceV1 } from "@opencode-ai/core/v1/config/reference" - -export type NormalizedEntry = - | { - kind: "local" - path: string - } - | { - kind: "git" - repository: string - branch?: string - } - | { - kind: "invalid" - message: string - } - -export type NormalizedInfo = Record - -export function validateAlias(name: string) { - if (name.length === 0) return "Reference alias must not be empty" - if (/[\/\s`,]/.test(name)) { - return "Reference alias must not contain /, whitespace, comma, or backtick" - } -} - -export function normalizeEntry(entry: ConfigReferenceV1.Entry): NormalizedEntry { - if (typeof entry === "string") { - if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) { - return { kind: "local", path: entry } - } - return { kind: "git", repository: entry } - } - - if ("path" in entry) return { kind: "local", path: entry.path } - return { kind: "git", repository: entry.repository, branch: entry.branch } -} - -export function normalize(info: ConfigReferenceV1.Info): NormalizedInfo { - return Object.fromEntries( - Object.entries(info).map(([name, entry]) => { - const aliasError = validateAlias(name) - return [name, aliasError ? { kind: "invalid" as const, message: aliasError } : normalizeEntry(entry)] as const - }), - ) -} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 3b34bcc4d8..ad6e5d695d 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -41,7 +41,7 @@ import { Format } from "@/format" import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Installation } from "@/installation" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 52fe4cc664..4d314da5fe 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -9,7 +9,7 @@ import { ShareNext } from "@/share/share-next" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" diff --git a/packages/opencode/src/reference.ts b/packages/opencode/src/reference.ts new file mode 100644 index 0000000000..4dd5c4780e --- /dev/null +++ b/packages/opencode/src/reference.ts @@ -0,0 +1,112 @@ +export * as Reference from "./reference" + +import * as InstanceState from "@/effect/instance-state" +import { Config } from "@/config/config" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { ProjectReference } from "@opencode-ai/core/project-reference" +import { ConfigReference } from "@opencode-ai/core/config/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Context, Effect, Layer, Schema, Scope } from "effect" + +export type Resolved = ProjectReference.Resolved + +export interface Interface { + readonly init: () => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (name: string) => Effect.Effect + readonly ensure: (target?: string) => Effect.Effect + readonly contains: (target?: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Reference") {} + +export function resolve(input: { + name: string + reference: ConfigReference.NormalizedEntry + directory: string + worktree: string +}) { + return ProjectReference.resolve({ + name: input.name, + reference: input.reference, + directory: input.worktree === "/" ? input.directory : input.worktree, + home: Global.Path.home, + repos: Global.Path.repos, + }) +} + +export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) { + return ProjectReference.resolveAll({ + references: input.references, + directory: input.worktree === "/" ? input.directory : input.worktree, + home: Global.Path.home, + repos: Global.Path.repos, + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const scope = yield* Scope.Scope + const state = yield* InstanceState.make( + Effect.fn("Reference.state")(function* (ctx) { + const { Config: ConfigV2 } = yield* Effect.promise(() => import("@opencode-ai/core/config")) + const cfg = yield* config.get() + const base = AbsolutePath.make(ctx.worktree === "/" ? ctx.directory : ctx.worktree) + const layer = ProjectReference.layer.pipe( + Layer.provide( + Layer.mergeAll( + FSUtil.defaultLayer, + Global.defaultLayer, + RepositoryCache.defaultLayer, + Layer.succeed( + Location.Service, + Location.Service.of({ directory: base, project: { id: ctx.project.id, directory: base } }), + ), + Layer.succeed( + ConfigV2.Service, + ConfigV2.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new ConfigV2.Loaded({ + source: { type: "memory" }, + info: Schema.decodeUnknownSync(ConfigV2.Info)({ references: cfg.reference }), + }), + ]), + }), + ), + ), + ), + ) + return Context.get(yield* Layer.build(layer), ProjectReference.Service) + }), + ) + + const ensure = Effect.fn("Reference.ensure")(function* (target?: string) { + yield* InstanceState.useEffect(state, (service) => service.ensurePath(target)).pipe(Effect.ignoreCause) + }) + + return Service.of({ + init: Effect.fn("Reference.init")(function* () { + yield* ensure().pipe(Effect.forkIn(scope), Effect.asVoid) + }), + list: Effect.fn("Reference.list")(function* () { + return yield* InstanceState.useEffect(state, (service) => service.list()) + }), + get: Effect.fn("Reference.get")(function* (name: string) { + return yield* InstanceState.useEffect(state, (service) => service.get(name)) + }), + ensure, + contains: Effect.fn("Reference.contains")(function* (target?: string) { + return yield* InstanceState.useEffect(state, (service) => service.containsManagedPath(target)) + }), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts deleted file mode 100644 index 03e17c27e9..0000000000 --- a/packages/opencode/src/reference/reference.ts +++ /dev/null @@ -1,239 +0,0 @@ -import path from "path" -import { Effect, Context, Layer, Scope } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Global } from "@opencode-ai/core/global" -import { Config } from "@/config/config" -import { ConfigReference } from "@/config/reference" -import { InstanceState } from "@/effect/instance-state" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository" -import { RepositoryCache } from "./repository-cache" - -export type Resolved = - | { - name: string - kind: "local" - path: string - } - | { - name: string - kind: "git" - repository: string - reference: RemoteReference - path: string - branch?: string - } - | { - name: string - kind: "invalid" - repository?: string - message: string - } - -type State = { - references: Resolved[] - materializeAll: Effect.Effect - materializeByPath: Materializer[] -} - -type Materializer = { path: string; run: Effect.Effect } - -export interface Interface { - readonly init: () => Effect.Effect - readonly list: () => Effect.Effect - readonly get: (name: string) => Effect.Effect - readonly ensure: (target?: string) => Effect.Effect - readonly contains: (target?: string) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Reference") {} - -export function referencePath(input: { directory: string; worktree: string; value: string }) { - if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2)) - return path.isAbsolute(input.value) - ? input.value - : path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value) -} - -function resolveGit( - input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined }, -): Resolved { - const parsed = parseRepositoryReference(input.repository) - if (!parsed || parsed.protocol === "file:") { - return { - name: input.name, - kind: "invalid", - repository: input.repository, - message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", - } - } - return { - name: input.name, - kind: "git", - repository: input.repository, - reference: parsed, - path: repositoryCachePath(parsed), - ...("branch" in input ? { branch: input.branch } : {}), - } -} - -function branchLabel(branch: string | undefined) { - return branch ?? "default branch" -} - -function normalizedTarget(target?: string) { - if (!target) return - return process.platform === "win32" ? FSUtil.normalizePath(target) : target -} - -function containsReferencePath(referencePath: string, target: string) { - return FSUtil.contains(normalizedTarget(referencePath) ?? referencePath, target) -} - -function uniqueGitReferences(references: Resolved[]) { - const seenPath = new Set() - return references.filter((reference): reference is Extract => { - if (reference.kind !== "git") return false - if (seenPath.has(reference.path)) return false - seenPath.add(reference.path) - return true - }) -} - -function materializeReference(cache: RepositoryCache.Interface, reference: Extract) { - return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe( - Effect.asVoid, - Effect.catchCause((cause) => - Effect.logWarning("failed to materialize reference repository").pipe( - Effect.annotateLogs({ name: reference.name, cause }), - ), - ), - ) -} - -const materializers = Effect.fn("Reference.materializers")(function* ( - cache: RepositoryCache.Interface, - references: Resolved[], -) { - return yield* Effect.forEach( - uniqueGitReferences(references), - Effect.fnUntraced(function* (reference) { - return { path: reference.path, run: yield* Effect.cached(materializeReference(cache, reference)) } - }), - { concurrency: "unbounded" }, - ) -}) - -function materializeAll(input: { flags: RuntimeFlags.Info; materializers: Materializer[] }) { - if (!input.flags.experimentalReferences) return Effect.void - return Effect.forEach( - input.materializers, - Effect.fnUntraced(function* (item) { - yield* item.run - }), - { concurrency: 4, discard: true }, - ) -} - -function materializeByPath(materializers: Materializer[], target: string) { - return materializers.find((item) => containsReferencePath(item.path, target))?.run ?? Effect.void -} - -function containsGitReferencePath(references: Resolved[], target: string) { - return references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, target)) -} - -export function resolve(input: { - name: string - reference: ConfigReference.NormalizedEntry - directory: string - worktree: string -}): Resolved { - if (input.reference.kind === "invalid") { - return { name: input.name, kind: "invalid", message: input.reference.message } - } - if (input.reference.kind === "local") { - return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) } - } - return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch }) -} - -export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) { - const seen = new Map() - return Object.entries(input.references).map(([name, reference]) => { - const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree }) - if (resolved.kind !== "git") return resolved - - const existing = seen.get(resolved.path) - if (!existing) { - seen.set(resolved.path, { name, branch: resolved.branch }) - return resolved - } - if (existing.branch === resolved.branch) return resolved - - return { - name, - kind: "invalid" as const, - repository: resolved.repository, - message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`, - } - }) -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const cache = yield* RepositoryCache.Service - const scope = yield* Scope.Scope - const flags = yield* RuntimeFlags.Service - - const state = yield* InstanceState.make( - Effect.fn("Reference.state")(function* (ctx) { - const cfg = yield* config.get() - const references = resolveAll({ - references: ConfigReference.normalize(cfg.reference ?? {}), - directory: ctx.directory, - worktree: ctx.worktree, - }) - const materializeByPath = yield* materializers(cache, references) - const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath })) - - return { references, materializeAll: materializeAllCached, materializeByPath } - }), - ) - - return Service.of({ - init: Effect.fn("Reference.init")(function* () { - if (!flags.experimentalReferences) return - yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid) - }), - list: Effect.fn("Reference.list")(function* () { - return yield* InstanceState.use(state, (s) => s.references) - }), - get: Effect.fn("Reference.get")(function* (name: string) { - return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name)) - }), - ensure: Effect.fn("Reference.ensure")(function* (target?: string) { - if (!flags.experimentalReferences) return - const full = normalizedTarget(target) - if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll) - return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full)) - }), - contains: Effect.fn("Reference.contains")(function* (target?: string) { - if (!flags.experimentalReferences) return false - const full = normalizedTarget(target) - if (!full) return false - return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full)) - }), - }) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - -export * as Reference from "./reference" diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts deleted file mode 100644 index 80e8071df5..0000000000 --- a/packages/opencode/src/reference/repository-cache.ts +++ /dev/null @@ -1,320 +0,0 @@ -import path from "path" -import { Context, Effect, Layer, Schema } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Flock } from "@opencode-ai/core/util/flock" -import { Git } from "@/git" -import { - repositoryCachePath, - sameRepositoryReference, - parseRepositoryReference, - parseRemoteRepositoryReference, - validateRepositoryBranch, - InvalidRepositoryBranchError, - InvalidRepositoryReferenceError, - UnsupportedLocalRepositoryError, - type RemoteReference, -} from "@/util/repository" - -export type Result = { - repository: string - host: string - remote: string - localPath: string - status: "cached" | "cloned" | "refreshed" - head?: string - branch?: string -} - -export type EnsureInput = { - reference: RemoteReference - refresh?: boolean - branch?: string -} - -export class InvalidRepositoryError extends Schema.TaggedErrorClass()( - "RepositoryCacheInvalidRepositoryError", - { - repository: Schema.String, - message: Schema.String, - }, -) {} - -export class InvalidBranchError extends Schema.TaggedErrorClass()( - "RepositoryCacheInvalidBranchError", - { - branch: Schema.String, - message: Schema.String, - }, -) {} - -export class CloneFailedError extends Schema.TaggedErrorClass()("RepositoryCacheCloneFailedError", { - repository: Schema.String, - message: Schema.String, -}) {} - -export class FetchFailedError extends Schema.TaggedErrorClass()("RepositoryCacheFetchFailedError", { - repository: Schema.String, - message: Schema.String, -}) {} - -export class CheckoutFailedError extends Schema.TaggedErrorClass()( - "RepositoryCacheCheckoutFailedError", - { - repository: Schema.String, - branch: Schema.String, - message: Schema.String, - }, -) {} - -export class ResetFailedError extends Schema.TaggedErrorClass()("RepositoryCacheResetFailedError", { - repository: Schema.String, - message: Schema.String, -}) {} - -export class LockFailedError extends Schema.TaggedErrorClass()("RepositoryCacheLockFailedError", { - localPath: Schema.String, - message: Schema.String, -}) {} - -export class CacheOperationError extends Schema.TaggedErrorClass()( - "RepositoryCacheOperationError", - { - operation: Schema.String, - path: Schema.String, - message: Schema.String, - }, -) {} - -export type Error = - | InvalidRepositoryError - | InvalidBranchError - | CloneFailedError - | FetchFailedError - | CheckoutFailedError - | ResetFailedError - | LockFailedError - | CacheOperationError - -export interface Interface { - ensure: (input: EnsureInput) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/RepositoryCache") {} - -function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { - if (!input.reuse) return "cloned" as const - if (input.branchMatches === false) return "refreshed" as const - if (input.refresh) return "refreshed" as const - return "cached" as const -} - -function resetTarget(input: { - requestedBranch?: string - remoteHead: { code: number; stdout: string } - branch: { code: number; stdout: string } -}) { - if (input.requestedBranch) return `origin/${input.requestedBranch}` - if (input.remoteHead.code === 0 && input.remoteHead.stdout) { - return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") - } - if (input.branch.code === 0 && input.branch.stdout) { - return `origin/${input.branch.stdout}` - } - return "HEAD" -} - -function errorMessage(error: unknown) { - return error instanceof globalThis.Error ? error.message : String(error) -} - -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 parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) { - try { - return parseRemoteRepositoryReference(repository) - } catch (error) { - if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) { - return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message }) - } - return yield* new InvalidRepositoryError({ - repository, - message: errorMessage(error), - }) - } -}) - -export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) { - try { - validateRepositoryBranch(branch) - } catch (error) { - if (error instanceof InvalidRepositoryBranchError) { - return yield* new InvalidBranchError({ branch: error.branch, message: error.message }) - } - return yield* new InvalidBranchError({ branch, message: errorMessage(error) }) - } -}) - -const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* ( - input: EnsureInput, - services: { - fs: FSUtil.Interface - git: Git.Interface - }, -) { - if (input.branch) yield* validateBranch(input.branch) - - const repository = input.reference.label - const remote = input.reference.remote - const localPath = repositoryCachePath(input.reference) - const cloneTarget = parseRepositoryReference(remote) ?? input.reference - - return yield* Effect.acquireUseRelease( - Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })).pipe( - Effect.catch((error: unknown) => - Effect.fail(new LockFailedError({ localPath, message: errorMessage(error) || `Failed to lock ${localPath}` })), - ), - ), - () => - Effect.gen(function* () { - yield* services.fs.ensureDir(path.dirname(localPath)).pipe( - Effect.catch((error: unknown) => - Effect.fail( - new CacheOperationError({ - operation: "ensure cache directory", - path: localPath, - message: errorMessage(error), - }), - ), - ), - ) - - const exists = yield* services.fs.existsSafe(localPath) - const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git")) - const origin = hasGitDir - ? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) - : undefined - const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined - const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) - if (exists && !reuse) { - yield* services.fs.remove(localPath, { recursive: true }).pipe( - Effect.catch((error: unknown) => - Effect.fail( - new CacheOperationError({ - operation: "remove stale cache", - path: localPath, - message: errorMessage(error), - }), - ), - ), - ) - } - - const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined - const status = statusForRepository({ - reuse, - refresh: input.refresh, - branchMatches: input.branch ? currentBranch === input.branch : undefined, - }) - - if (status === "cloned") { - const clone = yield* services.git.run( - ["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath], - { cwd: path.dirname(localPath) }, - ) - if (clone.exitCode !== 0) { - return yield* new CloneFailedError({ - repository, - message: clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`, - }) - } - } - - if (status === "refreshed") { - const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath }) - if (fetch.exitCode !== 0) { - return yield* new FetchFailedError({ - repository, - message: fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`, - }) - } - - if (input.branch) { - const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], { - cwd: localPath, - }) - if (checkout.exitCode !== 0) { - return yield* new CheckoutFailedError({ - repository, - branch: input.branch, - message: - checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`, - }) - } - } - - const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) - const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) - const target = resetTarget({ - requestedBranch: input.branch, - remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, - branch: { code: branch.exitCode, stdout: branch.text().trim() }, - }) - - const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath }) - if (reset.exitCode !== 0) { - return yield* new ResetFailedError({ - repository, - message: reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`, - }) - } - } - - const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath }) - const branch = yield* services.git.branch(localPath) - const headText = head.exitCode === 0 ? head.text().trim() : undefined - - return { - repository, - host: input.reference.host, - remote, - localPath, - status, - head: headText, - branch, - } satisfies Result - }), - (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), - ) -}) - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const git = yield* Git.Service - - return Service.of({ - ensure: Effect.fn("RepositoryCache.ensure")(function* (input) { - return yield* ensureWithServices(input, { fs, git }) - }), - }) - }), -) - -export const defaultLayer: Layer.Layer = layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), -) - -export * as RepositoryCache from "./repository-cache" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1486f203fb..ff6dc44c72 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -54,7 +54,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" diff --git a/packages/opencode/src/session/prompt/reference.ts b/packages/opencode/src/session/prompt/reference.ts index 4c7f9c65ce..f3420d3b3a 100644 --- a/packages/opencode/src/session/prompt/reference.ts +++ b/packages/opencode/src/session/prompt/reference.ts @@ -1,7 +1,7 @@ import { Option, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" import { MessageV2 } from "../message-v2" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" const Source = Schema.Struct({ value: Schema.String, diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 8dfb741031..087dada4a0 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,7 +7,7 @@ import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 2d161d57a0..95e768a333 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -7,7 +7,7 @@ import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" const MAX_LINE_LENGTH = 2000 diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 75526f2580..040f4d50a9 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -9,7 +9,7 @@ import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b639277d85..399099d0ca 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -47,7 +47,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" -import { Reference } from "@/reference/reference" +import { Reference } from "@/reference" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" diff --git a/packages/opencode/test/fixture/repository.ts b/packages/opencode/test/fixture/repository.ts new file mode 100644 index 0000000000..f9d1042fc5 --- /dev/null +++ b/packages/opencode/test/fixture/repository.ts @@ -0,0 +1,20 @@ +import { Effect, Semaphore } from "effect" + +const lock = Semaphore.makeUnsafe(1) + +export const githubBase = (url: string, self: Effect.Effect) => + lock.withPermit( + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + else process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + }), + ), + ) diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts index ea0934dd8a..b7645b83d2 100644 --- a/packages/opencode/test/reference/reference.test.ts +++ b/packages/opencode/test/reference/reference.test.ts @@ -5,24 +5,20 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" import { Config } from "../../src/config/config" -import { ConfigReference } from "../../src/config/reference" +import { ConfigReference } from "@opencode-ai/core/config/reference" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Git } from "../../src/git" -import { Reference } from "../../src/reference/reference" -import { RepositoryCache } from "../../src/reference/repository-cache" +import { Reference } from "../../src/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { githubBase } from "../fixture/repository" import { testEffect } from "../lib/effect" afterEach(async () => { await disposeAllInstances() }) -const referenceLayer = (flags: Partial = {}) => - Reference.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), - ) +const referenceLayer = (_flags: Partial = {}) => Reference.defaultLayer const it = testEffect( Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()), @@ -36,18 +32,25 @@ const references = testEffect( ), ) -const githubBase = (url: string, self: Effect.Effect) => +const withReferences = (self: Effect.Effect) => Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url - return previous - }), - () => self, + Effect.sync(() => process.env.OPENCODE_EXPERIMENTAL_REFERENCES), + () => Effect.sync(() => void (process.env.OPENCODE_EXPERIMENTAL_REFERENCES = "true")).pipe(Effect.andThen(self)), (previous) => Effect.sync(() => { - if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous - else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_REFERENCES + else process.env.OPENCODE_EXPERIMENTAL_REFERENCES = previous + }), + ) + +const withConfigContent = (content: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => process.env.OPENCODE_CONFIG_CONTENT), + () => Effect.sync(() => void (process.env.OPENCODE_CONFIG_CONTENT = content)).pipe(Effect.andThen(self)), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_CONFIG_CONTENT + else process.env.OPENCODE_CONFIG_CONTENT = previous }), ) @@ -129,28 +132,47 @@ describe("reference", () => { ) it.live("keeps invalid repository references visible without materializing", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const reference = yield* Reference.Service - const references = yield* reference.list() - const invalid = yield* reference.get("bad") + withReferences( + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + const references = yield* reference.list() + const invalid = yield* reference.get("bad") - expect(references.map((item) => item.name)).toEqual(["bad"]) - expect(invalid).toMatchObject({ - name: "bad", - kind: "invalid", - repository: "not-a-repo", - }) - if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL") - }), - { - config: { - reference: { - bad: "not-a-repo", + expect(references.map((item) => item.name)).toEqual(["bad"]) + expect(invalid).toMatchObject({ + name: "bad", + kind: "invalid", + repository: "not-a-repo", + }) + if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL") + }), + { + config: { + reference: { + bad: "not-a-repo", + }, }, }, - }, + ), + ), + ) + + references.live("reads references from legacy config content", () => + withReferences( + withConfigContent( + JSON.stringify({ reference: { docs: { path: "./docs" } } }), + provideTmpdirInstance((dir) => + Effect.gen(function* () { + expect(yield* (yield* Reference.Service).get("docs")).toMatchObject({ + name: "docs", + kind: "local", + path: path.join(dir, "docs"), + }) + }), + ), + ), ), ) @@ -198,113 +220,117 @@ describe("reference", () => { ) references.live("materializes configured git references during init", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") - yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + withReferences( + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-reference-test") - const remoteRepo = path.join(remoteDir, "repo.git") + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-test") + const remoteRepo = path.join(remoteDir, "repo.git") - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add readme"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const reference = yield* Reference.Service - yield* githubBase( - `file://${remoteRoot}/`, - Effect.gen(function* () { - yield* reference.init() - yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") - }), - ) + const reference = yield* Reference.Service + yield* githubBase( + `file://${remoteRoot}/`, + Effect.gen(function* () { + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") + }), + ) - expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) - expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") + expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) + expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") - const resolved = yield* reference.get("docs") - expect(resolved?.kind).toBe("git") - if (resolved?.kind === "git") expect(resolved.path).toBe(cache) - }), - { - config: { - reference: { - docs: "opencode-reference-test/repo", + const resolved = yield* reference.get("docs") + expect(resolved?.kind).toBe("git") + if (resolved?.kind === "git") expect(resolved.path).toBe(cache) + }), + { + config: { + reference: { + docs: "opencode-reference-test/repo", + }, }, }, - }, + ), ), ) references.live("refreshes configured git references on new instance init", () => - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") - yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + withReferences( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") - const remoteRepo = path.join(remoteDir, "repo.git") + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") + const remoteRepo = path.join(remoteDir, "repo.git") - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add readme"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - yield* githubBase( - `file://${remoteRoot}/`, - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const reference = yield* Reference.Service - yield* reference.init() - yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") - }), - { - config: { - reference: { - docs: "opencode-reference-refresh/repo", + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, }, }, - }, - ), - ) + ), + ) - const branch = yield* git(source, ["branch", "--show-current"]) - yield* git(source, ["remote", "add", "origin", remoteRepo]) - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "update readme"]) - yield* git(source, ["push", "origin", `${branch}:${branch}`]) + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) - yield* githubBase( - `file://${remoteRoot}/`, - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const reference = yield* Reference.Service - yield* reference.init() - yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") - }), - { - config: { - reference: { - docs: "opencode-reference-refresh/repo", + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, }, }, - }, - ), - ) - }), + ), + ) + }), + ), ) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cac9ada026..3f0d885d03 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -50,8 +50,8 @@ import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Format } from "../../src/format" -import { Reference } from "../../src/reference/reference" -import { RepositoryCache } from "../../src/reference/repository-cache" +import { Reference } from "../../src/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" @@ -92,6 +92,18 @@ function withSh(fx: () => Effect.Effect) { ) } +function withReferences(fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => process.env.OPENCODE_EXPERIMENTAL_REFERENCES), + () => Effect.sync(() => void (process.env.OPENCODE_EXPERIMENTAL_REFERENCES = "true")).pipe(Effect.andThen(fx())), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_REFERENCES + else process.env.OPENCODE_EXPERIMENTAL_REFERENCES = previous + }), + ) +} + function toolPart(parts: SessionV1.Part[]) { return parts.find((part): part is SessionV1.ToolPart => part.type === "tool") } @@ -1933,48 +1945,50 @@ noLLMServer.instance( noLLMServer.instance( "resolves configured reference mentions before workspace paths and agents", () => - Effect.gen(function* () { - const { directory: dir } = yield* TestInstance - const docs = path.join(dir, "external-docs") - yield* ensureDir(path.join(docs, "guide")) - yield* ensureDir(path.join(dir, "docs")) - yield* writeText(path.join(docs, "README.md"), "reference readme") - yield* writeText(path.join(docs, "guide", "intro.md"), "reference intro") - yield* writeText(path.join(dir, "docs", "README.md"), "workspace readme") + withReferences(() => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const docs = path.join(dir, "external-docs") + yield* ensureDir(path.join(docs, "guide")) + yield* ensureDir(path.join(dir, "docs")) + yield* writeText(path.join(docs, "README.md"), "reference readme") + yield* writeText(path.join(docs, "guide", "intro.md"), "reference intro") + yield* writeText(path.join(dir, "docs", "README.md"), "workspace readme") - const prompt = yield* SessionPrompt.Service - const parts = yield* prompt.resolvePromptParts( - "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", - ) - const references = parts.filter( - (part): part is SessionV1.TextPartInput => - part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), - ) - const files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file") - const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent") - const bare = references.find((part) => part.text.includes("@docs.")) - const missing = references.find((part) => part.text.includes("@docs/missing.md")) - const guide = files.find((part) => part.filename === "docs/guide") + const prompt = yield* SessionPrompt.Service + const parts = yield* prompt.resolvePromptParts( + "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", + ) + const references = parts.filter( + (part): part is SessionV1.TextPartInput => + part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), + ) + const files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file") + const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent") + const bare = references.find((part) => part.text.includes("@docs.")) + const missing = references.find((part) => part.text.includes("@docs/missing.md")) + const guide = files.find((part) => part.filename === "docs/guide") - expect(references.length).toBe(2) - expect(bare?.metadata?.reference).toMatchObject({ - name: "docs", - kind: "local", - path: docs, - }) - expect(missing?.text).toContain("Path does not exist inside configured reference @docs") - expect(missing?.metadata?.reference).toMatchObject({ - target: "missing.md", - targetPath: path.join(docs, "missing.md"), - }) + expect(references.length).toBe(2) + expect(bare?.metadata?.reference).toMatchObject({ + name: "docs", + kind: "local", + path: docs, + }) + expect(missing?.text).toContain("Path does not exist inside configured reference @docs") + expect(missing?.metadata?.reference).toMatchObject({ + target: "missing.md", + targetPath: path.join(docs, "missing.md"), + }) - expect(files.length).toBe(2) - expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual( - [path.join(docs, "README.md"), path.join(docs, "guide")].sort(), - ) - expect(guide?.mime).toBe("application/x-directory") - expect(agents.map((agent) => agent.name)).toEqual(["build"]) - }), + expect(files.length).toBe(2) + expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual( + [path.join(docs, "README.md"), path.join(docs, "guide")].sort(), + ) + expect(guide?.mime).toBe("application/x-directory") + expect(agents.map((agent) => agent.name)).toEqual(["build"]) + }), + ), { config: { ...cfg, @@ -1988,32 +2002,34 @@ noLLMServer.instance( noLLMServer.instance( "injects metadata for bare configured reference mentions", () => - Effect.gen(function* () { - const { directory: dir } = yield* TestInstance - const docs = path.join(dir, "external-docs") - yield* ensureDir(docs) + withReferences(() => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const docs = path.join(dir, "external-docs") + yield* ensureDir(docs) - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const message = yield* prompt.prompt({ - sessionID: session.id, - noReply: true, - parts: yield* prompt.resolvePromptParts("Use @docs for context"), - }) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const message = yield* prompt.prompt({ + sessionID: session.id, + noReply: true, + parts: yield* prompt.resolvePromptParts("Use @docs for context"), + }) - const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const synthetic = stored.parts.filter( - (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, - ) - const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const synthetic = stored.parts.filter( + (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, + ) + const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) - expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) - expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true) - expect(synthetic.some((part) => part.text.includes("Inspect the configured reference"))).toBe(true) + expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) + expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true) + expect(synthetic.some((part) => part.text.includes("Inspect the configured reference"))).toBe(true) - yield* sessions.remove(session.id) - }), + yield* sessions.remove(session.id) + }), + ), { config: { ...cfg, @@ -2027,58 +2043,60 @@ noLLMServer.instance( noLLMServer.instance( "injects metadata for configured reference file attachments", () => - Effect.gen(function* () { - const { directory: dir } = yield* TestInstance - const docs = path.join(dir, "external-docs") - const readme = path.join(docs, "README.md") - yield* ensureDir(docs) - yield* writeText(readme, "reference readme") + withReferences(() => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const docs = path.join(dir, "external-docs") + const readme = path.join(docs, "README.md") + yield* ensureDir(docs) + yield* writeText(readme, "reference readme") - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const message = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [ - { type: "text", text: "Read @docs/README.md" }, - { - type: "file", - mime: "text/plain", - filename: "docs/README.md", - url: pathToFileURL(readme).href, - source: { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const message = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "Read @docs/README.md" }, + { type: "file", - path: "docs/README.md", - text: { value: "@docs/README.md", start: 5, end: 20 }, + mime: "text/plain", + filename: "docs/README.md", + url: pathToFileURL(readme).href, + source: { + type: "file", + path: "docs/README.md", + text: { value: "@docs/README.md", start: 5, end: 20 }, + }, }, - }, - ], - }) + ], + }) - const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const synthetic = stored.parts.filter( - (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, - ) - const reference = synthetic.find((part) => - part.text.startsWith("Referenced configured reference @docs/README.md."), - ) + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const synthetic = stored.parts.filter( + (part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true, + ) + const reference = synthetic.find((part) => + part.text.startsWith("Referenced configured reference @docs/README.md."), + ) - expect(reference?.metadata?.reference).toMatchObject({ - name: "docs", - kind: "local", - path: docs, - target: "README.md", - targetPath: readme, - source: { value: "@docs/README.md", start: 5, end: 20 }, - }) - expect(synthetic.findIndex((part) => part === reference)).toBeLessThan( - synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")), - ) + expect(reference?.metadata?.reference).toMatchObject({ + name: "docs", + kind: "local", + path: docs, + target: "README.md", + targetPath: readme, + source: { value: "@docs/README.md", start: 5, end: 20 }, + }) + expect(synthetic.findIndex((part) => part === reference)).toBeLessThan( + synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")), + ) - yield* sessions.remove(session.id) - }), + yield* sessions.remove(session.id) + }), + ), { config: { ...cfg, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 5b86168ea9..e271209ba6 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -60,8 +60,8 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Format } from "../../src/format" -import { Reference } from "../../src/reference/reference" -import { RepositoryCache } from "../../src/reference/repository-cache" +import { Reference } from "../../src/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 6c5890edf6..fc6463edb8 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -12,20 +12,16 @@ import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Reference } from "@/reference/reference" -import { RepositoryCache } from "@/reference/repository-cache" +import { githubBase } from "../fixture/repository" +import { Reference } from "@/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" -const referenceLayer = (flags: Partial = {}) => - Reference.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), - ) +const referenceLayer = (_flags: Partial = {}) => Reference.defaultLayer const toolLayer = (flags: Partial = {}) => Layer.mergeAll( @@ -66,21 +62,6 @@ const asks = () => { } } -const githubBase = (url: string, self: Effect.Effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url - return previous - }), - () => self, - (previous) => - Effect.sync(() => { - if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous - else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - }), - ) - const git = Effect.fn("GlobToolTest.git")(function* (cwd: string, args: string[]) { return yield* Effect.promise(async () => { const proc = Bun.spawn(["git", ...args], { @@ -146,35 +127,37 @@ describe("tool.glob", () => { references.instance( "does not ask for external_directory permission inside configured git references", () => - Effect.gen(function* () { - yield* TestInstance - const fs = yield* FSUtil.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo") - yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + withReferences( + Effect.gen(function* () { + yield* TestInstance + const fs = yield* FSUtil.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-glob-reference") - const remoteRepo = path.join(remoteDir, "repo.git") - yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n") - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add source"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-glob-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add source"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const { items, next } = asks() - const info = yield* GlobTool - const glob = yield* info.init() - const result = yield* githubBase( - `file://${remoteRoot}/`, - glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next), - ) + const { items, next } = asks() + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* githubBase( + `file://${remoteRoot}/`, + glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next), + ) - expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(cache, "src", "index.ts")) - expect(items.find((item) => item.permission === "external_directory")).toBeUndefined() - }), + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(cache, "src", "index.ts")) + expect(items.find((item) => item.permission === "external_directory")).toBeUndefined() + }), + ), { config: { reference: { @@ -184,3 +167,15 @@ describe("tool.glob", () => { }, ) }) + +function withReferences(body: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => process.env.OPENCODE_EXPERIMENTAL_REFERENCES), + () => Effect.sync(() => void (process.env.OPENCODE_EXPERIMENTAL_REFERENCES = "true")).pipe(Effect.andThen(body)), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_REFERENCES + else process.env.OPENCODE_EXPERIMENTAL_REFERENCES = previous + }), + ) +} diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 2517da798e..0435a6fff8 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -14,8 +14,9 @@ import { Agent } from "../../src/agent/agent" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" -import { Reference } from "@/reference/reference" -import { RepositoryCache } from "@/reference/repository-cache" +import { githubBase } from "../fixture/repository" +import { Reference } from "@/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" import { Config } from "@/config/config" @@ -23,12 +24,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" import { Filesystem } from "@/util/filesystem" -const referenceLayer = (flags: Partial = {}) => - Reference.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), - ) +const referenceLayer = (_flags: Partial = {}) => Reference.defaultLayer const toolLayer = (flags: Partial = {}) => Layer.mergeAll( @@ -59,21 +55,6 @@ const ctx = { const root = path.join(__dirname, "../..") const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) -const githubBase = (url: string, self: Effect.Effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url - return previous - }), - () => self, - (previous) => - Effect.sync(() => { - if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous - else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - }), - ) - const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) { return yield* Effect.promise(async () => { const proc = Bun.spawn(["git", ...args], { @@ -218,43 +199,45 @@ describe("tool.grep", () => { references.instance( "does not ask for external_directory permission inside configured git references", () => - Effect.gen(function* () { - yield* TestInstance - const appfs = yield* FSUtil.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo") - yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + withReferences( + Effect.gen(function* () { + yield* TestInstance + const appfs = yield* FSUtil.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo") + yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-grep-reference") - const remoteRepo = path.join(remoteDir, "repo.git") - yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n") - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add notes"]) - yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-grep-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const requests: Array> = [] - const next: Tool.Context = { - ...ctx, - ask: (req) => - Effect.sync(() => { - requests.push(req) - }), - } + const requests: Array> = [] + const next: Tool.Context = { + ...ctx, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), + } - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* githubBase( - `file://${remoteRoot}/`, - grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next), - ) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* githubBase( + `file://${remoteRoot}/`, + grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next), + ) - expect(result.metadata.matches).toBe(1) - expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md"))) - expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined() - }), + expect(result.metadata.matches).toBe(1) + expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md"))) + expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined() + }), + ), { config: { reference: { @@ -264,3 +247,15 @@ describe("tool.grep", () => { }, ) }) + +function withReferences(body: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => process.env.OPENCODE_EXPERIMENTAL_REFERENCES), + () => Effect.sync(() => void (process.env.OPENCODE_EXPERIMENTAL_REFERENCES = "true")).pipe(Effect.andThen(body)), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_REFERENCES + else process.env.OPENCODE_EXPERIMENTAL_REFERENCES = previous + }), + ) +} diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 5135743938..5a3b0c519a 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -24,8 +24,9 @@ import { tmpdirScoped, } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Reference } from "@/reference/reference" -import { RepositoryCache } from "@/reference/repository-cache" +import { githubBase } from "../fixture/repository" +import { Reference } from "@/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -44,12 +45,7 @@ const ctx = { ask: () => Effect.void, } -const referenceLayer = (flags: Partial = {}) => - Reference.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), - ) +const referenceLayer = (_flags: Partial = {}) => Reference.defaultLayer const readLayer = (flags: Partial = {}) => Layer.mergeAll( @@ -102,20 +98,6 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") -const githubBase = (url: string, self: Effect.Effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url - return previous - }), - () => self, - (previous) => - Effect.sync(() => { - if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous - else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL - }), - ) const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) { return yield* Effect.promise(async () => { const proc = Bun.spawn(["git", ...args], { @@ -265,44 +247,58 @@ describe("tool.read external_directory permission", () => { ) references.live("does not ask for external_directory permission when reading configured references", () => - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") - yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + withReferences( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-read-reference") - const remoteRepo = path.join(remoteDir, "repo.git") - yield* put(path.join(source, "notes.md"), "reference notes") - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add notes"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-read-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* put(path.join(source, "notes.md"), "reference notes") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const dir = yield* tmpdirScoped({ - git: true, - config: { - reference: { - docs: "opencode-read-reference/repo", + const dir = yield* tmpdirScoped({ + git: true, + config: { + reference: { + docs: "opencode-read-reference/repo", + }, }, - }, - }) + }) - const { items, next } = asks() - const result = yield* githubBase( - `file://${remoteRoot}/`, - exec(dir, { filePath: path.join(cache, "notes.md") }, next), - ) - const ext = items.find((item) => item.permission === "external_directory") + const { items, next } = asks() + const result = yield* githubBase( + `file://${remoteRoot}/`, + exec(dir, { filePath: path.join(cache, "notes.md") }, next), + ) + const ext = items.find((item) => item.permission === "external_directory") - expect(result.output).toContain("reference notes") - expect(ext).toBeUndefined() - }), + expect(result.output).toContain("reference notes") + expect(ext).toBeUndefined() + }), + ), ) }) +function withReferences(body: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => process.env.OPENCODE_EXPERIMENTAL_REFERENCES), + () => Effect.sync(() => void (process.env.OPENCODE_EXPERIMENTAL_REFERENCES = "true")).pipe(Effect.andThen(body)), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_REFERENCES + else process.env.OPENCODE_EXPERIMENTAL_REFERENCES = previous + }), + ) +} + describe("tool.read env file permissions", () => { const cases: [string, boolean][] = [ [".env", true], diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 42b8c69b3b..e6b7a8aabe 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -29,8 +29,8 @@ import { Format } from "@/format" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" -import { Reference } from "@/reference/reference" -import { RepositoryCache } from "@/reference/repository-cache" +import { Reference } from "@/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema"