Compare commits
7 commits
dev
...
nxl/consol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb7b299e5 | ||
|
|
330faabf9a | ||
|
|
cfe8327e19 | ||
|
|
cc198ce525 | ||
|
|
c5175b6cc1 | ||
|
|
00b3212b82 | ||
|
|
0ce1866e8e |
24 changed files with 653 additions and 1026 deletions
|
|
@ -73,7 +73,7 @@ export const layer = Layer.effect(
|
||||||
references: ConfigReference.normalize(
|
references: ConfigReference.normalize(
|
||||||
Object.assign({}, ...(yield* config.get()).map((document) => document.info.references ?? {})),
|
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,
|
home: global.home,
|
||||||
repos: global.repos,
|
repos: global.repos,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ import { Location } from "@opencode-ai/core/location"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
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 {
|
return {
|
||||||
directory: ref.directory,
|
directory: ref.directory,
|
||||||
workspaceID: ref.workspaceID,
|
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,
|
vcs: input.vcs,
|
||||||
} satisfies Location.Interface
|
} satisfies Location.Interface
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||||
import { Repository } from "@opencode-ai/core/repository"
|
import { Repository } from "@opencode-ai/core/repository"
|
||||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
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", () =>
|
it.live("merges config aliases and exposes mention and managed-path operations", () =>
|
||||||
withoutReferences(
|
withoutReferences(
|
||||||
withTmp((tmp) => {
|
withTmp((tmp) => {
|
||||||
|
|
@ -139,6 +198,7 @@ describe("ProjectReference", () => {
|
||||||
testLayer({
|
testLayer({
|
||||||
directory: nested,
|
directory: nested,
|
||||||
project,
|
project,
|
||||||
|
vcs: { type: "git", store: AbsolutePath.make(path.join(project, ".git")) },
|
||||||
repos,
|
repos,
|
||||||
documents: [
|
documents: [
|
||||||
document({ docs: { path: "./old-docs" }, sdk: "owner/old" }),
|
document({ docs: { path: "./old-docs" }, sdk: "owner/old" }),
|
||||||
|
|
@ -236,6 +296,8 @@ function result(
|
||||||
function testLayer(input: {
|
function testLayer(input: {
|
||||||
directory: string
|
directory: string
|
||||||
project: string
|
project: string
|
||||||
|
projectID?: Project.ID
|
||||||
|
vcs?: Project.Vcs
|
||||||
repos: string
|
repos: string
|
||||||
documents: Config.Loaded[]
|
documents: Config.Loaded[]
|
||||||
ensure: RepositoryCache.Interface["ensure"]
|
ensure: RepositoryCache.Interface["ensure"]
|
||||||
|
|
@ -250,7 +312,11 @@ function testLayer(input: {
|
||||||
Location.Service.of(
|
Location.Service.of(
|
||||||
location(
|
location(
|
||||||
{ directory: AbsolutePath.make(input.directory) },
|
{ 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,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ import { Locale } from "@/util/locale"
|
||||||
import type { PromptInfo } from "./history"
|
import type { PromptInfo } from "./history"
|
||||||
import { useFrecency } from "./frecency"
|
import { useFrecency } from "./frecency"
|
||||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
import { ConfigReference } from "@/config/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"
|
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
|
||||||
|
|
||||||
function removeLineRange(input: string) {
|
function removeLineRange(input: string) {
|
||||||
|
|
@ -329,11 +330,13 @@ export function Autocomplete(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const references = createMemo(() =>
|
const references = createMemo(() =>
|
||||||
Reference.resolveAll({
|
Flag.OPENCODE_EXPERIMENTAL_REFERENCES
|
||||||
references: ConfigReference.normalize(sync.data.config.reference ?? {}),
|
? Reference.resolveAll({
|
||||||
directory: sync.path.directory || process.cwd(),
|
references: ConfigReference.normalize(sync.data.config.reference ?? {}),
|
||||||
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
|
directory: sync.path.directory || process.cwd(),
|
||||||
}),
|
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
|
||||||
|
})
|
||||||
|
: [],
|
||||||
)
|
)
|
||||||
|
|
||||||
const referenceSearch = createMemo(() => {
|
const referenceSearch = createMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -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<string, NormalizedEntry>
|
|
||||||
|
|
||||||
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
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -41,7 +41,7 @@ import { Format } from "@/format"
|
||||||
import { InstanceLayer } from "@/project/instance-layer"
|
import { InstanceLayer } from "@/project/instance-layer"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { ShareNext } from "@/share/share-next"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Service } from "./bootstrap-service"
|
import { Service } from "./bootstrap-service"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
|
|
||||||
export { Service } from "./bootstrap-service"
|
export { Service } from "./bootstrap-service"
|
||||||
export type { Interface } from "./bootstrap-service"
|
export type { Interface } from "./bootstrap-service"
|
||||||
|
|
|
||||||
112
packages/opencode/src/reference.ts
Normal file
112
packages/opencode/src/reference.ts
Normal file
|
|
@ -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<void>
|
||||||
|
readonly list: () => Effect.Effect<Resolved[]>
|
||||||
|
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||||
|
readonly ensure: (target?: string) => Effect.Effect<void>
|
||||||
|
readonly contains: (target?: string) => Effect.Effect<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@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))
|
||||||
|
|
@ -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<void>
|
|
||||||
materializeByPath: Materializer[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type Materializer = { path: string; run: Effect.Effect<void> }
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Resolved[]>
|
|
||||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
|
||||||
readonly ensure: (target?: string) => Effect.Effect<void>
|
|
||||||
readonly contains: (target?: string) => Effect.Effect<boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@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<string>()
|
|
||||||
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
|
||||||
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<Resolved, { kind: "git" }>) {
|
|
||||||
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<string, { name: string; branch?: string }>()
|
|
||||||
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<State>(
|
|
||||||
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"
|
|
||||||
|
|
@ -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<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 {
|
|
||||||
ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@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<Service, never, FSUtil.Service | Git.Service> = 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<Service> = layer.pipe(
|
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export * as RepositoryCache from "./repository-cache"
|
|
||||||
|
|
@ -54,7 +54,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
|
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 * as DateTime from "effect/DateTime"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Option, Schema } from "effect"
|
import { Option, Schema } from "effect"
|
||||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||||
import { MessageV2 } from "../message-v2"
|
import { MessageV2 } from "../message-v2"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
|
|
||||||
const Source = Schema.Struct({
|
const Source = Schema.Struct({
|
||||||
value: Schema.String,
|
value: Schema.String,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
|
|
||||||
export const Parameters = Schema.Struct({
|
export const Parameters = Schema.Struct({
|
||||||
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
|
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { InstanceState } from "@/effect/instance-state"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import { Instruction } from "../session/instruction"
|
import { Instruction } from "../session/instruction"
|
||||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
import { BackgroundJob } from "@/background/job"
|
import { BackgroundJob } from "@/background/job"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
|
|
||||||
20
packages/opencode/test/fixture/repository.ts
Normal file
20
packages/opencode/test/fixture/repository.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Effect, Semaphore } from "effect"
|
||||||
|
|
||||||
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
|
|
||||||
|
export const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -5,24 +5,20 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Config } from "../../src/config/config"
|
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 { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||||
import { Git } from "../../src/git"
|
import { Git } from "../../src/git"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference"
|
||||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||||
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { githubBase } from "../fixture/repository"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await disposeAllInstances()
|
await disposeAllInstances()
|
||||||
})
|
})
|
||||||
|
|
||||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const referenceLayer = (_flags: Partial<RuntimeFlags.Info> = {}) => Reference.defaultLayer
|
||||||
Reference.layer.pipe(
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(RepositoryCache.defaultLayer),
|
|
||||||
Layer.provide(RuntimeFlags.layer(flags)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
||||||
|
|
@ -36,18 +32,25 @@ const references = testEffect(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
const withReferences = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => process.env.OPENCODE_EXPERIMENTAL_REFERENCES),
|
||||||
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
() => Effect.sync(() => void (process.env.OPENCODE_EXPERIMENTAL_REFERENCES = "true")).pipe(Effect.andThen(self)),
|
||||||
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
|
|
||||||
return previous
|
|
||||||
}),
|
|
||||||
() => self,
|
|
||||||
(previous) =>
|
(previous) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_REFERENCES
|
||||||
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
else process.env.OPENCODE_EXPERIMENTAL_REFERENCES = previous
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const withConfigContent = <A, E, R>(content: string, self: Effect.Effect<A, E, R>) =>
|
||||||
|
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", () =>
|
it.live("keeps invalid repository references visible without materializing", () =>
|
||||||
provideTmpdirInstance(
|
withReferences(
|
||||||
(_dir) =>
|
provideTmpdirInstance(
|
||||||
Effect.gen(function* () {
|
(_dir) =>
|
||||||
const reference = yield* Reference.Service
|
Effect.gen(function* () {
|
||||||
const references = yield* reference.list()
|
const reference = yield* Reference.Service
|
||||||
const invalid = yield* reference.get("bad")
|
const references = yield* reference.list()
|
||||||
|
const invalid = yield* reference.get("bad")
|
||||||
|
|
||||||
expect(references.map((item) => item.name)).toEqual(["bad"])
|
expect(references.map((item) => item.name)).toEqual(["bad"])
|
||||||
expect(invalid).toMatchObject({
|
expect(invalid).toMatchObject({
|
||||||
name: "bad",
|
name: "bad",
|
||||||
kind: "invalid",
|
kind: "invalid",
|
||||||
repository: "not-a-repo",
|
repository: "not-a-repo",
|
||||||
})
|
})
|
||||||
if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL")
|
if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL")
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
bad: "not-a-repo",
|
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", () =>
|
references.live("materializes configured git references during init", () =>
|
||||||
provideTmpdirInstance(
|
withReferences(
|
||||||
(_dir) =>
|
provideTmpdirInstance(
|
||||||
Effect.gen(function* () {
|
(_dir) =>
|
||||||
const fs = yield* FSUtil.Service
|
Effect.gen(function* () {
|
||||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo")
|
const fs = yield* FSUtil.Service
|
||||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo")
|
||||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
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 source = yield* tmpdirScoped({ git: true })
|
||||||
const remoteRoot = yield* tmpdirScoped()
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
const remoteDir = path.join(remoteRoot, "opencode-reference-test")
|
const remoteDir = path.join(remoteRoot, "opencode-reference-test")
|
||||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
|
||||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n"))
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n"))
|
||||||
yield* git(source, ["add", "."])
|
yield* git(source, ["add", "."])
|
||||||
yield* git(source, ["commit", "-m", "add readme"])
|
yield* git(source, ["commit", "-m", "add readme"])
|
||||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
yield* githubBase(
|
yield* githubBase(
|
||||||
`file://${remoteRoot}/`,
|
`file://${remoteRoot}/`,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* reference.init()
|
yield* reference.init()
|
||||||
yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n")
|
yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true)
|
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.readFileString(path.join(cache, "README.md"))).toBe("configured\n")
|
||||||
|
|
||||||
const resolved = yield* reference.get("docs")
|
const resolved = yield* reference.get("docs")
|
||||||
expect(resolved?.kind).toBe("git")
|
expect(resolved?.kind).toBe("git")
|
||||||
if (resolved?.kind === "git") expect(resolved.path).toBe(cache)
|
if (resolved?.kind === "git") expect(resolved.path).toBe(cache)
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
docs: "opencode-reference-test/repo",
|
docs: "opencode-reference-test/repo",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
references.live("refreshes configured git references on new instance init", () =>
|
references.live("refreshes configured git references on new instance init", () =>
|
||||||
Effect.gen(function* () {
|
withReferences(
|
||||||
const fs = yield* FSUtil.Service
|
Effect.gen(function* () {
|
||||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
|
const fs = yield* FSUtil.Service
|
||||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
|
||||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
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 source = yield* tmpdirScoped({ git: true })
|
||||||
const remoteRoot = yield* tmpdirScoped()
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
const remoteDir = path.join(remoteRoot, "opencode-reference-refresh")
|
const remoteDir = path.join(remoteRoot, "opencode-reference-refresh")
|
||||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
|
||||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||||
yield* git(source, ["add", "."])
|
yield* git(source, ["add", "."])
|
||||||
yield* git(source, ["commit", "-m", "add readme"])
|
yield* git(source, ["commit", "-m", "add readme"])
|
||||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
yield* githubBase(
|
yield* githubBase(
|
||||||
`file://${remoteRoot}/`,
|
`file://${remoteRoot}/`,
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(_dir) =>
|
(_dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
yield* reference.init()
|
yield* reference.init()
|
||||||
yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n")
|
yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n")
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
docs: "opencode-reference-refresh/repo",
|
docs: "opencode-reference-refresh/repo",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
const branch = yield* git(source, ["branch", "--show-current"])
|
const branch = yield* git(source, ["branch", "--show-current"])
|
||||||
yield* git(source, ["remote", "add", "origin", remoteRepo])
|
yield* git(source, ["remote", "add", "origin", remoteRepo])
|
||||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
|
||||||
yield* git(source, ["add", "."])
|
yield* git(source, ["add", "."])
|
||||||
yield* git(source, ["commit", "-m", "update readme"])
|
yield* git(source, ["commit", "-m", "update readme"])
|
||||||
yield* git(source, ["push", "origin", `${branch}:${branch}`])
|
yield* git(source, ["push", "origin", `${branch}:${branch}`])
|
||||||
|
|
||||||
yield* githubBase(
|
yield* githubBase(
|
||||||
`file://${remoteRoot}/`,
|
`file://${remoteRoot}/`,
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(_dir) =>
|
(_dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
yield* reference.init()
|
yield* reference.init()
|
||||||
yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n")
|
yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n")
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
docs: "opencode-reference-refresh/repo",
|
docs: "opencode-reference-refresh/repo",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
),
|
)
|
||||||
)
|
}),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference"
|
||||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||||
import { TestInstance } from "../fixture/fixture"
|
import { TestInstance } from "../fixture/fixture"
|
||||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||||
|
|
@ -92,6 +92,18 @@ function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withReferences<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
|
||||||
|
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[]) {
|
function toolPart(parts: SessionV1.Part[]) {
|
||||||
return parts.find((part): part is SessionV1.ToolPart => part.type === "tool")
|
return parts.find((part): part is SessionV1.ToolPart => part.type === "tool")
|
||||||
}
|
}
|
||||||
|
|
@ -1933,48 +1945,50 @@ noLLMServer.instance(
|
||||||
noLLMServer.instance(
|
noLLMServer.instance(
|
||||||
"resolves configured reference mentions before workspace paths and agents",
|
"resolves configured reference mentions before workspace paths and agents",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
withReferences(() =>
|
||||||
const { directory: dir } = yield* TestInstance
|
Effect.gen(function* () {
|
||||||
const docs = path.join(dir, "external-docs")
|
const { directory: dir } = yield* TestInstance
|
||||||
yield* ensureDir(path.join(docs, "guide"))
|
const docs = path.join(dir, "external-docs")
|
||||||
yield* ensureDir(path.join(dir, "docs"))
|
yield* ensureDir(path.join(docs, "guide"))
|
||||||
yield* writeText(path.join(docs, "README.md"), "reference readme")
|
yield* ensureDir(path.join(dir, "docs"))
|
||||||
yield* writeText(path.join(docs, "guide", "intro.md"), "reference intro")
|
yield* writeText(path.join(docs, "README.md"), "reference readme")
|
||||||
yield* writeText(path.join(dir, "docs", "README.md"), "workspace 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 prompt = yield* SessionPrompt.Service
|
||||||
const parts = yield* prompt.resolvePromptParts(
|
const parts = yield* prompt.resolvePromptParts(
|
||||||
"Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
|
"Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
|
||||||
)
|
)
|
||||||
const references = parts.filter(
|
const references = parts.filter(
|
||||||
(part): part is SessionV1.TextPartInput =>
|
(part): part is SessionV1.TextPartInput =>
|
||||||
part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "),
|
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 files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file")
|
||||||
const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent")
|
const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent")
|
||||||
const bare = references.find((part) => part.text.includes("@docs."))
|
const bare = references.find((part) => part.text.includes("@docs."))
|
||||||
const missing = references.find((part) => part.text.includes("@docs/missing.md"))
|
const missing = references.find((part) => part.text.includes("@docs/missing.md"))
|
||||||
const guide = files.find((part) => part.filename === "docs/guide")
|
const guide = files.find((part) => part.filename === "docs/guide")
|
||||||
|
|
||||||
expect(references.length).toBe(2)
|
expect(references.length).toBe(2)
|
||||||
expect(bare?.metadata?.reference).toMatchObject({
|
expect(bare?.metadata?.reference).toMatchObject({
|
||||||
name: "docs",
|
name: "docs",
|
||||||
kind: "local",
|
kind: "local",
|
||||||
path: docs,
|
path: docs,
|
||||||
})
|
})
|
||||||
expect(missing?.text).toContain("Path does not exist inside configured reference @docs")
|
expect(missing?.text).toContain("Path does not exist inside configured reference @docs")
|
||||||
expect(missing?.metadata?.reference).toMatchObject({
|
expect(missing?.metadata?.reference).toMatchObject({
|
||||||
target: "missing.md",
|
target: "missing.md",
|
||||||
targetPath: path.join(docs, "missing.md"),
|
targetPath: path.join(docs, "missing.md"),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(files.length).toBe(2)
|
expect(files.length).toBe(2)
|
||||||
expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual(
|
expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual(
|
||||||
[path.join(docs, "README.md"), path.join(docs, "guide")].sort(),
|
[path.join(docs, "README.md"), path.join(docs, "guide")].sort(),
|
||||||
)
|
)
|
||||||
expect(guide?.mime).toBe("application/x-directory")
|
expect(guide?.mime).toBe("application/x-directory")
|
||||||
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
...cfg,
|
...cfg,
|
||||||
|
|
@ -1988,32 +2002,34 @@ noLLMServer.instance(
|
||||||
noLLMServer.instance(
|
noLLMServer.instance(
|
||||||
"injects metadata for bare configured reference mentions",
|
"injects metadata for bare configured reference mentions",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
withReferences(() =>
|
||||||
const { directory: dir } = yield* TestInstance
|
Effect.gen(function* () {
|
||||||
const docs = path.join(dir, "external-docs")
|
const { directory: dir } = yield* TestInstance
|
||||||
yield* ensureDir(docs)
|
const docs = path.join(dir, "external-docs")
|
||||||
|
yield* ensureDir(docs)
|
||||||
|
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const session = yield* sessions.create({})
|
const session = yield* sessions.create({})
|
||||||
const message = yield* prompt.prompt({
|
const message = yield* prompt.prompt({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
noReply: true,
|
noReply: true,
|
||||||
parts: yield* prompt.resolvePromptParts("Use @docs for context"),
|
parts: yield* prompt.resolvePromptParts("Use @docs for context"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||||
const synthetic = stored.parts.filter(
|
const synthetic = stored.parts.filter(
|
||||||
(part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
|
(part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
|
||||||
)
|
)
|
||||||
const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs."))
|
const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs."))
|
||||||
|
|
||||||
expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: 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(`Reference root: ${docs}`))).toBe(true)
|
||||||
expect(synthetic.some((part) => part.text.includes("Inspect the configured reference"))).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: {
|
config: {
|
||||||
...cfg,
|
...cfg,
|
||||||
|
|
@ -2027,58 +2043,60 @@ noLLMServer.instance(
|
||||||
noLLMServer.instance(
|
noLLMServer.instance(
|
||||||
"injects metadata for configured reference file attachments",
|
"injects metadata for configured reference file attachments",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
withReferences(() =>
|
||||||
const { directory: dir } = yield* TestInstance
|
Effect.gen(function* () {
|
||||||
const docs = path.join(dir, "external-docs")
|
const { directory: dir } = yield* TestInstance
|
||||||
const readme = path.join(docs, "README.md")
|
const docs = path.join(dir, "external-docs")
|
||||||
yield* ensureDir(docs)
|
const readme = path.join(docs, "README.md")
|
||||||
yield* writeText(readme, "reference readme")
|
yield* ensureDir(docs)
|
||||||
|
yield* writeText(readme, "reference readme")
|
||||||
|
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const session = yield* sessions.create({})
|
const session = yield* sessions.create({})
|
||||||
const message = yield* prompt.prompt({
|
const message = yield* prompt.prompt({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: "build",
|
agent: "build",
|
||||||
noReply: true,
|
noReply: true,
|
||||||
parts: [
|
parts: [
|
||||||
{ type: "text", text: "Read @docs/README.md" },
|
{ type: "text", text: "Read @docs/README.md" },
|
||||||
{
|
{
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
filename: "docs/README.md",
|
|
||||||
url: pathToFileURL(readme).href,
|
|
||||||
source: {
|
|
||||||
type: "file",
|
type: "file",
|
||||||
path: "docs/README.md",
|
mime: "text/plain",
|
||||||
text: { value: "@docs/README.md", start: 5, end: 20 },
|
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 stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||||
const synthetic = stored.parts.filter(
|
const synthetic = stored.parts.filter(
|
||||||
(part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
|
(part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
|
||||||
)
|
)
|
||||||
const reference = synthetic.find((part) =>
|
const reference = synthetic.find((part) =>
|
||||||
part.text.startsWith("Referenced configured reference @docs/README.md."),
|
part.text.startsWith("Referenced configured reference @docs/README.md."),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(reference?.metadata?.reference).toMatchObject({
|
expect(reference?.metadata?.reference).toMatchObject({
|
||||||
name: "docs",
|
name: "docs",
|
||||||
kind: "local",
|
kind: "local",
|
||||||
path: docs,
|
path: docs,
|
||||||
target: "README.md",
|
target: "README.md",
|
||||||
targetPath: readme,
|
targetPath: readme,
|
||||||
source: { value: "@docs/README.md", start: 5, end: 20 },
|
source: { value: "@docs/README.md", start: 5, end: 20 },
|
||||||
})
|
})
|
||||||
expect(synthetic.findIndex((part) => part === reference)).toBeLessThan(
|
expect(synthetic.findIndex((part) => part === reference)).toBeLessThan(
|
||||||
synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")),
|
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: {
|
config: {
|
||||||
...cfg,
|
...cfg,
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference"
|
||||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,16 @@ import { Truncate } from "@/tool/truncate"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
|
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Reference } from "@/reference/reference"
|
import { githubBase } from "../fixture/repository"
|
||||||
import { RepositoryCache } from "@/reference/repository-cache"
|
import { Reference } from "@/reference"
|
||||||
|
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import type * as Tool from "../../src/tool/tool"
|
import type * as Tool from "../../src/tool/tool"
|
||||||
|
|
||||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const referenceLayer = (_flags: Partial<RuntimeFlags.Info> = {}) => Reference.defaultLayer
|
||||||
Reference.layer.pipe(
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(RepositoryCache.defaultLayer),
|
|
||||||
Layer.provide(RuntimeFlags.layer(flags)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
|
|
@ -66,21 +62,6 @@ const asks = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
|
||||||
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[]) {
|
const git = Effect.fn("GlobToolTest.git")(function* (cwd: string, args: string[]) {
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const proc = Bun.spawn(["git", ...args], {
|
const proc = Bun.spawn(["git", ...args], {
|
||||||
|
|
@ -146,35 +127,37 @@ describe("tool.glob", () => {
|
||||||
references.instance(
|
references.instance(
|
||||||
"does not ask for external_directory permission inside configured git references",
|
"does not ask for external_directory permission inside configured git references",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
withReferences(
|
||||||
yield* TestInstance
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
yield* TestInstance
|
||||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
|
const fs = yield* FSUtil.Service
|
||||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
|
||||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
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 source = yield* tmpdirScoped({ git: true })
|
||||||
const remoteRoot = yield* tmpdirScoped()
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
const remoteDir = path.join(remoteRoot, "opencode-glob-reference")
|
const remoteDir = path.join(remoteRoot, "opencode-glob-reference")
|
||||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n")
|
yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n")
|
||||||
yield* git(source, ["add", "."])
|
yield* git(source, ["add", "."])
|
||||||
yield* git(source, ["commit", "-m", "add source"])
|
yield* git(source, ["commit", "-m", "add source"])
|
||||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
const { items, next } = asks()
|
const { items, next } = asks()
|
||||||
const info = yield* GlobTool
|
const info = yield* GlobTool
|
||||||
const glob = yield* info.init()
|
const glob = yield* info.init()
|
||||||
const result = yield* githubBase(
|
const result = yield* githubBase(
|
||||||
`file://${remoteRoot}/`,
|
`file://${remoteRoot}/`,
|
||||||
glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next),
|
glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.metadata.count).toBe(1)
|
expect(result.metadata.count).toBe(1)
|
||||||
expect(result.output).toContain(path.join(cache, "src", "index.ts"))
|
expect(result.output).toContain(path.join(cache, "src", "index.ts"))
|
||||||
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
|
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
|
|
@ -184,3 +167,15 @@ describe("tool.glob", () => {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ import { Agent } from "../../src/agent/agent"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Reference } from "@/reference/reference"
|
import { githubBase } from "../fixture/repository"
|
||||||
import { RepositoryCache } from "@/reference/repository-cache"
|
import { Reference } from "@/reference"
|
||||||
|
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import type * as Tool from "../../src/tool/tool"
|
import type * as Tool from "../../src/tool/tool"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
|
@ -23,12 +24,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
|
||||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const referenceLayer = (_flags: Partial<RuntimeFlags.Info> = {}) => Reference.defaultLayer
|
||||||
Reference.layer.pipe(
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(RepositoryCache.defaultLayer),
|
|
||||||
Layer.provide(RuntimeFlags.layer(flags)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
|
|
@ -59,21 +55,6 @@ const ctx = {
|
||||||
const root = path.join(__dirname, "../..")
|
const root = path.join(__dirname, "../..")
|
||||||
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
||||||
|
|
||||||
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
|
||||||
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[]) {
|
const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) {
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const proc = Bun.spawn(["git", ...args], {
|
const proc = Bun.spawn(["git", ...args], {
|
||||||
|
|
@ -218,43 +199,45 @@ describe("tool.grep", () => {
|
||||||
references.instance(
|
references.instance(
|
||||||
"does not ask for external_directory permission inside configured git references",
|
"does not ask for external_directory permission inside configured git references",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
withReferences(
|
||||||
yield* TestInstance
|
Effect.gen(function* () {
|
||||||
const appfs = yield* FSUtil.Service
|
yield* TestInstance
|
||||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
|
const appfs = yield* FSUtil.Service
|
||||||
yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
|
||||||
yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
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 source = yield* tmpdirScoped({ git: true })
|
||||||
const remoteRoot = yield* tmpdirScoped()
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
const remoteDir = path.join(remoteRoot, "opencode-grep-reference")
|
const remoteDir = path.join(remoteRoot, "opencode-grep-reference")
|
||||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n")
|
yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n")
|
||||||
yield* git(source, ["add", "."])
|
yield* git(source, ["add", "."])
|
||||||
yield* git(source, ["commit", "-m", "add notes"])
|
yield* git(source, ["commit", "-m", "add notes"])
|
||||||
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||||
const next: Tool.Context = {
|
const next: Tool.Context = {
|
||||||
...ctx,
|
...ctx,
|
||||||
ask: (req) =>
|
ask: (req) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
requests.push(req)
|
requests.push(req)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = yield* GrepTool
|
const info = yield* GrepTool
|
||||||
const grep = yield* info.init()
|
const grep = yield* info.init()
|
||||||
const result = yield* githubBase(
|
const result = yield* githubBase(
|
||||||
`file://${remoteRoot}/`,
|
`file://${remoteRoot}/`,
|
||||||
grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next),
|
grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.metadata.matches).toBe(1)
|
expect(result.metadata.matches).toBe(1)
|
||||||
expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md")))
|
expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md")))
|
||||||
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
|
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
|
|
@ -264,3 +247,15 @@ describe("tool.grep", () => {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ import {
|
||||||
tmpdirScoped,
|
tmpdirScoped,
|
||||||
} from "../fixture/fixture"
|
} from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Reference } from "@/reference/reference"
|
import { githubBase } from "../fixture/repository"
|
||||||
import { RepositoryCache } from "@/reference/repository-cache"
|
import { Reference } from "@/reference"
|
||||||
|
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||||
|
|
||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
|
|
||||||
|
|
@ -44,12 +45,7 @@ const ctx = {
|
||||||
ask: () => Effect.void,
|
ask: () => Effect.void,
|
||||||
}
|
}
|
||||||
|
|
||||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const referenceLayer = (_flags: Partial<RuntimeFlags.Info> = {}) => Reference.defaultLayer
|
||||||
Reference.layer.pipe(
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(RepositoryCache.defaultLayer),
|
|
||||||
Layer.provide(RuntimeFlags.layer(flags)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
Layer.mergeAll(
|
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 full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
||||||
const glob = (p: string) =>
|
const glob = (p: string) =>
|
||||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||||
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
|
||||||
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[]) {
|
const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) {
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const proc = Bun.spawn(["git", ...args], {
|
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", () =>
|
references.live("does not ask for external_directory permission when reading configured references", () =>
|
||||||
Effect.gen(function* () {
|
withReferences(
|
||||||
const fs = yield* FSUtil.Service
|
Effect.gen(function* () {
|
||||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
|
const fs = yield* FSUtil.Service
|
||||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
|
||||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
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 source = yield* tmpdirScoped({ git: true })
|
||||||
const remoteRoot = yield* tmpdirScoped()
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
const remoteDir = path.join(remoteRoot, "opencode-read-reference")
|
const remoteDir = path.join(remoteRoot, "opencode-read-reference")
|
||||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
yield* put(path.join(source, "notes.md"), "reference notes")
|
yield* put(path.join(source, "notes.md"), "reference notes")
|
||||||
yield* git(source, ["add", "."])
|
yield* git(source, ["add", "."])
|
||||||
yield* git(source, ["commit", "-m", "add notes"])
|
yield* git(source, ["commit", "-m", "add notes"])
|
||||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
const dir = yield* tmpdirScoped({
|
const dir = yield* tmpdirScoped({
|
||||||
git: true,
|
git: true,
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
docs: "opencode-read-reference/repo",
|
docs: "opencode-read-reference/repo",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const { items, next } = asks()
|
const { items, next } = asks()
|
||||||
const result = yield* githubBase(
|
const result = yield* githubBase(
|
||||||
`file://${remoteRoot}/`,
|
`file://${remoteRoot}/`,
|
||||||
exec(dir, { filePath: path.join(cache, "notes.md") }, next),
|
exec(dir, { filePath: path.join(cache, "notes.md") }, next),
|
||||||
)
|
)
|
||||||
const ext = items.find((item) => item.permission === "external_directory")
|
const ext = items.find((item) => item.permission === "external_directory")
|
||||||
|
|
||||||
expect(result.output).toContain("reference notes")
|
expect(result.output).toContain("reference notes")
|
||||||
expect(ext).toBeUndefined()
|
expect(ext).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||||
|
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", () => {
|
describe("tool.read env file permissions", () => {
|
||||||
const cases: [string, boolean][] = [
|
const cases: [string, boolean][] = [
|
||||||
[".env", true],
|
[".env", true],
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ import { Format } from "@/format"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import * as Truncate from "@/tool/truncate"
|
import * as Truncate from "@/tool/truncate"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference"
|
||||||
import { RepositoryCache } from "@/reference/repository-cache"
|
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||||
|
|
||||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue