feat(scout): materialize configured reference repos (#26692)
This commit is contained in:
parent
903d81819d
commit
5cf9abe743
19 changed files with 848 additions and 200 deletions
|
|
@ -26,9 +26,7 @@ import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
import { zod } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
|
||||||
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
|
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
|
@ -303,69 +301,72 @@ export const layer = Layer.effect(
|
||||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function referencePath(value: string) {
|
function referencePrompt(reference: Reference.Resolved) {
|
||||||
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
|
|
||||||
return path.isAbsolute(value)
|
|
||||||
? value
|
|
||||||
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReference(reference: ReferenceEntry): ResolvedReference {
|
|
||||||
if (typeof reference === "string") {
|
|
||||||
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
|
|
||||||
return { kind: "local", path: referencePath(reference) }
|
|
||||||
}
|
|
||||||
return { kind: "git", repository: reference }
|
|
||||||
}
|
|
||||||
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
|
|
||||||
return { kind: "git", repository: reference.repository, branch: reference.branch }
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencePrompt(name: string, reference: ResolvedReference) {
|
|
||||||
if (reference.kind === "local") {
|
if (reference.kind === "local") {
|
||||||
return [
|
return [
|
||||||
PROMPT_SCOUT,
|
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||||
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
|
|
||||||
`Local directory: ${reference.path}`,
|
`Local directory: ${reference.path}`,
|
||||||
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
||||||
|
`Return exact absolute file paths for findings whenever possible.`,
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.kind === "invalid") {
|
||||||
|
return [
|
||||||
|
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
|
||||||
|
`Configured repository: ${reference.repository}`,
|
||||||
|
`Problem: ${reference.message}`,
|
||||||
|
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PROMPT_SCOUT,
|
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||||
`You are Scout reference @${name}. This reference points to a git repository.`,
|
|
||||||
`Repository: ${reference.repository}`,
|
`Repository: ${reference.repository}`,
|
||||||
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||||
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
|
`Cached directory: ${reference.path}`,
|
||||||
|
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
|
||||||
|
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
|
||||||
|
`Return exact absolute file paths for findings whenever possible.`,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function referenceDescription(reference: Reference.Resolved) {
|
||||||
|
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
|
||||||
|
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
|
||||||
|
return `Invalid Scout reference for repository ${reference.repository}`
|
||||||
|
}
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
||||||
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
|
const resolvedReferences = Reference.resolveAll({
|
||||||
if (agents[name]) continue
|
references: cfg.reference ?? {},
|
||||||
const resolved = resolveReference(reference)
|
directory: ctx.directory,
|
||||||
const localPath = resolved.kind === "local" ? resolved.path : undefined
|
worktree: ctx.worktree,
|
||||||
agents[name] = {
|
})
|
||||||
name,
|
for (const resolved of resolvedReferences) {
|
||||||
description:
|
if (agents[resolved.name]) continue
|
||||||
resolved.kind === "local"
|
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
|
||||||
? `Scout reference for local directory ${resolved.path}`
|
agents[resolved.name] = {
|
||||||
: `Scout reference for repository ${resolved.repository}`,
|
name: resolved.name,
|
||||||
|
description: referenceDescription(resolved),
|
||||||
permission: Permission.merge(
|
permission: Permission.merge(
|
||||||
agents.scout.permission,
|
agents.scout.permission,
|
||||||
Permission.fromConfig(
|
Permission.fromConfig(
|
||||||
localPath
|
{
|
||||||
? {
|
repo_clone: "deny",
|
||||||
external_directory: {
|
...(localPath
|
||||||
[localPath]: "allow",
|
? {
|
||||||
[path.join(localPath, "*")]: "allow",
|
external_directory: {
|
||||||
},
|
[localPath]: "allow",
|
||||||
}
|
[path.join(localPath, "*")]: "allow",
|
||||||
: {},
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
prompt: referencePrompt(name, resolved),
|
prompt: referencePrompt(resolved),
|
||||||
options: { reference },
|
options: { reference: cfg.reference?.[resolved.name], resolved },
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
native: false,
|
native: false,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,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 { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
|
|
@ -96,6 +97,7 @@ export const AppLayer = Layer.mergeAll(
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
Workspace.defaultLayer,
|
Workspace.defaultLayer,
|
||||||
Worktree.appLayer,
|
Worktree.appLayer,
|
||||||
Pty.defaultLayer,
|
Pty.defaultLayer,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,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"
|
||||||
|
|
||||||
export { Service } from "./bootstrap-service"
|
export { Service } from "./bootstrap-service"
|
||||||
export type { Interface } from "./bootstrap-service"
|
export type { Interface } from "./bootstrap-service"
|
||||||
|
|
@ -29,6 +30,7 @@ export const layer = Layer.effect(
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const project = yield* Project.Service
|
const project = yield* Project.Service
|
||||||
|
const reference = yield* Reference.Service
|
||||||
const shareNext = yield* ShareNext.Service
|
const shareNext = yield* ShareNext.Service
|
||||||
const snapshot = yield* Snapshot.Service
|
const snapshot = yield* Snapshot.Service
|
||||||
const vcs = yield* Vcs.Service
|
const vcs = yield* Vcs.Service
|
||||||
|
|
@ -43,7 +45,7 @@ export const layer = Layer.effect(
|
||||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||||
// its per-instance state scope. We just await materialization here.
|
// its per-instance state scope. We just await materialization here.
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||||
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||||
{ concurrency: "unbounded", discard: true },
|
{ concurrency: "unbounded", discard: true },
|
||||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||||
|
|
@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
|
|
|
||||||
228
packages/opencode/src/reference/reference.ts
Normal file
228
packages/opencode/src/reference/reference.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Context, Layer, Scope } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
|
||||||
|
import { RepositoryCache } from "./repository-cache"
|
||||||
|
|
||||||
|
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||||
|
|
||||||
|
export type Resolved =
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
kind: "local"
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
kind: "git"
|
||||||
|
repository: string
|
||||||
|
reference: RepositoryReference
|
||||||
|
path: string
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
kind: "invalid"
|
||||||
|
repository: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
references: Resolved[]
|
||||||
|
materializeAll: Effect.Effect<void>
|
||||||
|
materializeByPath: { 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" ? AppFileSystem.normalizePath(target) : target
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsReferencePath(referencePath: string, target: string) {
|
||||||
|
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolve(input: { name: string; reference: ReferenceEntry; directory: string; worktree: string }): Resolved {
|
||||||
|
if (typeof input.reference === "string") {
|
||||||
|
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
|
||||||
|
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
|
||||||
|
}
|
||||||
|
return resolveGit({ name: input.name, repository: input.reference })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("path" in input.reference) {
|
||||||
|
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: NonNullable<Config.Info["reference"]>
|
||||||
|
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 fs = yield* AppFileSystem.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Reference.state")(function* (ctx) {
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
const references = resolveAll({ references: cfg.reference ?? {}, directory: ctx.directory, worktree: ctx.worktree })
|
||||||
|
const seenPath = new Set<string>()
|
||||||
|
const gitReferences = 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
|
||||||
|
})
|
||||||
|
const materializeByPath = yield* Effect.forEach(
|
||||||
|
gitReferences,
|
||||||
|
Effect.fnUntraced(function* (reference) {
|
||||||
|
const run = yield* Effect.cached(
|
||||||
|
RepositoryCache.ensure(
|
||||||
|
{ reference: reference.reference, branch: reference.branch, refresh: true },
|
||||||
|
{ fs, git },
|
||||||
|
).pipe(
|
||||||
|
Effect.asVoid,
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return { path: reference.path, run }
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const materializeAll = yield* Effect.cached(
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||||
|
? Effect.gen(function* () {
|
||||||
|
yield* Effect.forEach(
|
||||||
|
materializeByPath,
|
||||||
|
Effect.fnUntraced(function* (item) {
|
||||||
|
yield* item.run
|
||||||
|
}),
|
||||||
|
{ concurrency: 4, discard: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: Effect.void,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { references, materializeAll, materializeByPath }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
init: Effect.fn("Reference.init")(function* () {
|
||||||
|
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) 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 (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
|
||||||
|
const full = normalizedTarget(target)
|
||||||
|
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||||
|
return yield* InstanceState.useEffect(
|
||||||
|
state,
|
||||||
|
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||||
|
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false
|
||||||
|
const full = normalizedTarget(target)
|
||||||
|
if (!full) return false
|
||||||
|
return yield* InstanceState.use(state, (s) =>
|
||||||
|
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Reference from "./reference"
|
||||||
155
packages/opencode/src/reference/repository-cache.ts
Normal file
155
packages/opencode/src/reference/repository-cache.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Flock } from "@opencode-ai/core/util/flock"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import {
|
||||||
|
repositoryCachePath,
|
||||||
|
sameRepositoryReference,
|
||||||
|
parseRepositoryReference,
|
||||||
|
validateRepositoryBranch,
|
||||||
|
type Reference as RepositoryReference,
|
||||||
|
} from "@/util/repository"
|
||||||
|
|
||||||
|
export type Result = {
|
||||||
|
repository: string
|
||||||
|
host: string
|
||||||
|
remote: string
|
||||||
|
localPath: string
|
||||||
|
status: "cached" | "cloned" | "refreshed"
|
||||||
|
head?: string
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
||||||
|
input: {
|
||||||
|
reference: RepositoryReference
|
||||||
|
refresh?: boolean
|
||||||
|
branch?: string
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
fs: AppFileSystem.Interface
|
||||||
|
git: Git.Interface
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (input.branch) validateRepositoryBranch(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 })),
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
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.orDie)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
throw new Error(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) {
|
||||||
|
throw new Error(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) {
|
||||||
|
throw new Error(
|
||||||
|
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) {
|
||||||
|
throw new Error(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 * as RepositoryCache from "./repository-cache"
|
||||||
|
|
@ -7,6 +7,7 @@ import { Ripgrep } from "../file/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"
|
||||||
|
|
||||||
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" }),
|
||||||
|
|
@ -20,6 +21,7 @@ export const GlobTool = Tool.define(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const reference = yield* Reference.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
|
|
@ -39,11 +41,15 @@ export const GlobTool = Tool.define(
|
||||||
|
|
||||||
let search = params.path ?? ins.directory
|
let search = params.path ?? ins.directory
|
||||||
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
||||||
|
yield* reference.ensure(search)
|
||||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (info?.type === "File") {
|
if (info?.type === "File") {
|
||||||
throw new Error(`glob path must be a directory: ${search}`)
|
throw new Error(`glob path must be a directory: ${search}`)
|
||||||
}
|
}
|
||||||
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||||
|
bypass: yield* reference.contains(search),
|
||||||
|
kind: "directory",
|
||||||
|
})
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
let truncated = false
|
let truncated = false
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Ripgrep } from "../file/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"
|
||||||
|
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ export const GrepTool = Tool.define(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
|
const reference = yield* Reference.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
|
|
@ -57,10 +59,12 @@ export const GrepTool = Tool.define(
|
||||||
? (params.path ?? ins.directory)
|
? (params.path ?? ins.directory)
|
||||||
: path.join(ins.directory, params.path ?? "."),
|
: path.join(ins.directory, params.path ?? "."),
|
||||||
)
|
)
|
||||||
|
yield* reference.ensure(search)
|
||||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||||
|
bypass: yield* reference.contains(search),
|
||||||
kind: info?.type === "Directory" ? "directory" : "file",
|
kind: info?.type === "Directory" ? "directory" : "file",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,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"
|
||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
|
|
@ -41,6 +42,7 @@ export const ReadTool = Tool.define(
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
|
const reference = yield* Reference.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
||||||
|
|
@ -162,6 +164,7 @@ export const ReadTool = Tool.define(
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
filepath = AppFileSystem.normalizePath(filepath)
|
filepath = AppFileSystem.normalizePath(filepath)
|
||||||
}
|
}
|
||||||
|
yield* reference.ensure(filepath)
|
||||||
const title = path.relative(instance.worktree, filepath)
|
const title = path.relative(instance.worktree, filepath)
|
||||||
|
|
||||||
const stat = yield* fs.stat(filepath).pipe(
|
const stat = yield* fs.stat(filepath).pipe(
|
||||||
|
|
@ -172,7 +175,7 @@ export const ReadTool = Tool.define(
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* assertExternalDirectoryEffect(ctx, filepath, {
|
yield* assertExternalDirectoryEffect(ctx, filepath, {
|
||||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)),
|
||||||
kind: stat?.type === "Directory" ? "directory" : "file",
|
kind: stat?.type === "Directory" ? "directory" : "file",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import { Agent } from "../agent/agent"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
|
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
|
|
||||||
|
|
@ -91,6 +92,7 @@ export const layer: Layer.Layer<
|
||||||
| Session.Service
|
| Session.Service
|
||||||
| Provider.Service
|
| Provider.Service
|
||||||
| Git.Service
|
| Git.Service
|
||||||
|
| Reference.Service
|
||||||
| LSP.Service
|
| LSP.Service
|
||||||
| Instruction.Service
|
| Instruction.Service
|
||||||
| AppFileSystem.Service
|
| AppFileSystem.Service
|
||||||
|
|
@ -361,6 +363,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import path from "path"
|
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Flock } from "@opencode-ai/core/util/flock"
|
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import DESCRIPTION from "./repo_clone.txt"
|
import DESCRIPTION from "./repo_clone.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository"
|
import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository"
|
||||||
|
import { RepositoryCache } from "@/reference/repository-cache"
|
||||||
|
|
||||||
export const Parameters = Schema.Struct({
|
export const Parameters = Schema.Struct({
|
||||||
repository: Schema.String.annotate({
|
repository: Schema.String.annotate({
|
||||||
|
|
@ -29,36 +28,6 @@ type Metadata = {
|
||||||
branch?: string
|
branch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
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 validateBranch(branch: string) {
|
|
||||||
if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
|
|
||||||
throw new Error(
|
|
||||||
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
||||||
"repo_clone",
|
"repo_clone",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -70,16 +39,12 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const reference = parseRepositoryReference(params.repository)
|
const reference = parseRemoteRepositoryReference(params.repository)
|
||||||
if (!reference)
|
if (params.branch) validateRepositoryBranch(params.branch)
|
||||||
throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
|
||||||
if (reference.protocol === "file:") throw new Error("Local file repositories are not supported")
|
|
||||||
if (params.branch) validateBranch(params.branch)
|
|
||||||
|
|
||||||
const repository = reference.label
|
const repository = reference.label
|
||||||
const remote = reference.remote
|
const remote = reference.remote
|
||||||
const localPath = repositoryCachePath(reference)
|
const localPath = repositoryCachePath(reference)
|
||||||
const cloneTarget = parseRepositoryReference(remote) ?? reference
|
|
||||||
|
|
||||||
yield* ctx.ask({
|
yield* ctx.ask({
|
||||||
permission: "repo_clone",
|
permission: "repo_clone",
|
||||||
|
|
@ -94,115 +59,21 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return yield* Effect.acquireUseRelease(
|
const result = yield* RepositoryCache.ensure(
|
||||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
{ reference, refresh: params.refresh, branch: params.branch },
|
||||||
() =>
|
{ fs, git },
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
|
||||||
|
|
||||||
const exists = yield* fs.existsSafe(localPath)
|
|
||||||
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
|
||||||
const origin = hasGitDir
|
|
||||||
? yield* 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* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined
|
|
||||||
const status = statusForRepository({
|
|
||||||
reuse,
|
|
||||||
refresh: params.refresh,
|
|
||||||
branchMatches: params.branch ? currentBranch === params.branch : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (status === "cloned") {
|
|
||||||
const clone = yield* git.run(
|
|
||||||
[
|
|
||||||
"clone",
|
|
||||||
"--depth",
|
|
||||||
"100",
|
|
||||||
...(params.branch ? ["--branch", params.branch] : []),
|
|
||||||
"--",
|
|
||||||
remote,
|
|
||||||
localPath,
|
|
||||||
],
|
|
||||||
{ cwd: path.dirname(localPath) },
|
|
||||||
)
|
|
||||||
if (clone.exitCode !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "refreshed") {
|
|
||||||
const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
|
||||||
if (fetch.exitCode !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.branch) {
|
|
||||||
const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], {
|
|
||||||
cwd: localPath,
|
|
||||||
})
|
|
||||||
if (checkout.exitCode !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
checkout.stderr.toString().trim() ||
|
|
||||||
checkout.text().trim() ||
|
|
||||||
`Failed to checkout ${params.branch}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
|
||||||
const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
|
||||||
const target = resetTarget({
|
|
||||||
requestedBranch: params.branch,
|
|
||||||
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
|
||||||
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
|
||||||
})
|
|
||||||
|
|
||||||
const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath })
|
|
||||||
if (reset.exitCode !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
|
||||||
const branch = yield* git.branch(localPath)
|
|
||||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: repository,
|
|
||||||
metadata: {
|
|
||||||
repository,
|
|
||||||
host: reference.host,
|
|
||||||
remote,
|
|
||||||
localPath,
|
|
||||||
status,
|
|
||||||
head: headText,
|
|
||||||
branch,
|
|
||||||
},
|
|
||||||
output: [
|
|
||||||
`Repository ready: ${repository}`,
|
|
||||||
`Status: ${status}`,
|
|
||||||
`Local path: ${localPath}`,
|
|
||||||
...(branch ? [`Branch: ${branch}`] : []),
|
|
||||||
...(headText ? [`HEAD: ${headText}`] : []),
|
|
||||||
].join("\n"),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
title: repository,
|
||||||
|
metadata: result,
|
||||||
|
output: [
|
||||||
|
`Repository ready: ${repository}`,
|
||||||
|
`Status: ${result.status}`,
|
||||||
|
`Local path: ${localPath}`,
|
||||||
|
...(result.branch ? [`Branch: ${result.branch}`] : []),
|
||||||
|
...(result.head ? [`HEAD: ${result.head}`] : []),
|
||||||
|
].join("\n"),
|
||||||
|
}
|
||||||
}).pipe(Effect.orDie),
|
}).pipe(Effect.orDie),
|
||||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,21 @@ export function parseRepositoryReference(input: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseRemoteRepositoryReference(input: string) {
|
||||||
|
const reference = parseRepositoryReference(input)
|
||||||
|
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||||
|
if (reference.protocol === "file:") throw new Error("Local file repositories are not supported")
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRepositoryBranch(branch: string) {
|
||||||
|
if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
|
||||||
|
throw new Error(
|
||||||
|
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseGitHubRemote(input: string) {
|
export function parseGitHubRemote(input: string) {
|
||||||
const cleaned = normalize(input)
|
const cleaned = normalize(input)
|
||||||
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
|
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,10 @@ test("reference config creates scout-backed subagents", async () => {
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
effect: "github.com/effect/effect-smol",
|
effect: "github.com/effect/effect-smol",
|
||||||
|
effectDev: {
|
||||||
|
repository: "https://github.com/effect/effect-smol",
|
||||||
|
branch: "dev",
|
||||||
|
},
|
||||||
effectFull: {
|
effectFull: {
|
||||||
repository: "Effect-TS/effect",
|
repository: "Effect-TS/effect",
|
||||||
branch: "main",
|
branch: "main",
|
||||||
|
|
@ -162,6 +166,7 @@ test("reference config creates scout-backed subagents", async () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const effect = await load(tmp.path, (svc) => svc.get("effect"))
|
const effect = await load(tmp.path, (svc) => svc.get("effect"))
|
||||||
|
const effectDev = await load(tmp.path, (svc) => svc.get("effectDev"))
|
||||||
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
|
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
|
||||||
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
|
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
|
||||||
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
|
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
|
||||||
|
|
@ -169,13 +174,19 @@ test("reference config creates scout-backed subagents", async () => {
|
||||||
expect(effect).toBeDefined()
|
expect(effect).toBeDefined()
|
||||||
expect(effect?.mode).toBe("subagent")
|
expect(effect?.mode).toBe("subagent")
|
||||||
expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
|
expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
|
||||||
expect(evalPerm(effect, "repo_clone")).toBe("allow")
|
expect(effect?.prompt).toContain(`Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`)
|
||||||
|
expect(effect?.prompt).toContain("Do not call repo_clone")
|
||||||
|
expect(evalPerm(effect, "repo_clone")).toBe("deny")
|
||||||
|
|
||||||
|
expect(effectDev).toBeDefined()
|
||||||
|
expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect")
|
||||||
|
expect(effectDev?.prompt).not.toContain("Cached directory:")
|
||||||
|
|
||||||
expect(effectFull).toBeDefined()
|
expect(effectFull).toBeDefined()
|
||||||
expect(effectFull?.mode).toBe("subagent")
|
expect(effectFull?.mode).toBe("subagent")
|
||||||
expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
|
expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
|
||||||
expect(effectFull?.prompt).toContain("Branch/ref: main")
|
expect(effectFull?.prompt).toContain("Branch/ref: main")
|
||||||
expect(evalPerm(effectFull, "repo_clone")).toBe("allow")
|
expect(evalPerm(effectFull, "repo_clone")).toBe("deny")
|
||||||
|
|
||||||
expect(local).toBeDefined()
|
expect(local).toBeDefined()
|
||||||
expect(local?.mode).toBe("subagent")
|
expect(local?.mode).toBe("subagent")
|
||||||
|
|
|
||||||
249
packages/opencode/test/reference/reference.test.ts
Normal file
249
packages/opencode/test/reference/reference.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Git } from "../../src/git"
|
||||||
|
import { Reference } from "../../src/reference/reference"
|
||||||
|
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await disposeAllInstances()
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
Git.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const experimentalScout = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_SCOUT = true
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() => self,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
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("ReferenceTest.git")(function* (cwd: string, args: string[]) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const proc = Bun.spawn(["git", ...args], {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
])
|
||||||
|
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
||||||
|
return stdout.trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitForContent = (
|
||||||
|
fs: AppFileSystem.Interface,
|
||||||
|
file: string,
|
||||||
|
content: string,
|
||||||
|
attempts = 50,
|
||||||
|
): Effect.Effect<void, AppFileSystem.Error> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if ((yield* fs.readFileStringSafe(file)) === content) return
|
||||||
|
if (attempts <= 0) throw new Error(`timed out waiting for ${file}`)
|
||||||
|
yield* Effect.sleep("100 millis")
|
||||||
|
yield* waitForContent(fs, file, content, attempts - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("reference", () => {
|
||||||
|
it.live("resolves local and git references", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const root = path.resolve("opencode-reference-root")
|
||||||
|
const local = Reference.resolve({
|
||||||
|
name: "docs",
|
||||||
|
reference: { path: "../docs" },
|
||||||
|
directory: path.join(root, "packages", "app"),
|
||||||
|
worktree: root,
|
||||||
|
})
|
||||||
|
const repo = Reference.resolve({
|
||||||
|
name: "effect",
|
||||||
|
reference: { repository: "Effect-TS/effect", branch: "main" },
|
||||||
|
directory: path.join(root, "packages", "app"),
|
||||||
|
worktree: root,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(local.kind).toBe("local")
|
||||||
|
if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs"))
|
||||||
|
expect(repo.kind).toBe("git")
|
||||||
|
if (repo.kind === "git") {
|
||||||
|
expect(repo.repository).toBe("Effect-TS/effect")
|
||||||
|
expect(repo.branch).toBe("main")
|
||||||
|
expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("marks same-cache references with different branches invalid", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const root = path.resolve("opencode-reference-root")
|
||||||
|
const references = Reference.resolveAll({
|
||||||
|
directory: root,
|
||||||
|
worktree: root,
|
||||||
|
references: {
|
||||||
|
main: { repository: "owner/repo", branch: "main" },
|
||||||
|
dev: { repository: "github.com/owner/repo", branch: "dev" },
|
||||||
|
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
|
||||||
|
expect(references[1]?.kind).toBe("invalid")
|
||||||
|
if (references[1]?.kind === "invalid") {
|
||||||
|
expect(references[1].message).toContain("conflicts with @main")
|
||||||
|
expect(references[1].message).toContain("@dev requests dev")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("materializes configured git references during init", () =>
|
||||||
|
experimentalScout(
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo")
|
||||||
|
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||||
|
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||||
|
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "opencode-reference-test")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n"))
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add readme"])
|
||||||
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
const reference = yield* Reference.Service
|
||||||
|
yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* reference.init()
|
||||||
|
yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true)
|
||||||
|
expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n")
|
||||||
|
|
||||||
|
const resolved = yield* reference.get("docs")
|
||||||
|
expect(resolved?.kind).toBe("git")
|
||||||
|
if (resolved?.kind === "git") expect(resolved.path).toBe(cache)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
reference: {
|
||||||
|
docs: "opencode-reference-test/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("refreshes configured git references on new instance init", () =>
|
||||||
|
experimentalScout(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
|
||||||
|
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||||
|
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||||
|
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "opencode-reference-refresh")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add readme"])
|
||||||
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const reference = yield* Reference.Service
|
||||||
|
yield* reference.init()
|
||||||
|
yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n")
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
reference: {
|
||||||
|
docs: "opencode-reference-refresh/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const branch = yield* git(source, ["branch", "--show-current"])
|
||||||
|
yield* git(source, ["remote", "add", "origin", remoteRepo])
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "update readme"])
|
||||||
|
yield* git(source, ["push", "origin", `${branch}:${branch}`])
|
||||||
|
|
||||||
|
yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const reference = yield* Reference.Service
|
||||||
|
yield* reference.init()
|
||||||
|
yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n")
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
reference: {
|
||||||
|
docs: "opencode-reference-refresh/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -46,6 +46,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import * as Database from "../../src/storage/db"
|
import * as Database from "../../src/storage/db"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Reference } from "../../src/reference/reference"
|
||||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||||
|
|
@ -181,6 +182,7 @@ function makeHttp() {
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Reference } from "../../src/reference/reference"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|
||||||
|
|
@ -131,6 +132,7 @@ function makeHttp() {
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Truncate } from "@/tool/truncate"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { TestInstance } from "../fixture/fixture"
|
import { TestInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
|
|
@ -18,6 +19,7 @@ const it = testEffect(
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Agent } from "../../src/agent/agent"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
|
|
@ -18,6 +19,7 @@ const it = testEffect(
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import path from "path"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
|
@ -15,6 +17,7 @@ import { Tool } from "@/tool/tool"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
|
|
||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
|
|
||||||
|
|
@ -40,6 +43,7 @@ const it = testEffect(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -81,6 +85,49 @@ 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 experimentalScout = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_SCOUT = true
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() => self,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
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[]) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const proc = Bun.spawn(["git", ...args], {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
])
|
||||||
|
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
||||||
|
return stdout.trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
|
const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
yield* fs.writeWithDirs(p, content)
|
yield* fs.writeWithDirs(p, content)
|
||||||
|
|
@ -212,6 +259,46 @@ describe("tool.read external_directory permission", () => {
|
||||||
expect(ext).toBeUndefined()
|
expect(ext).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("does not ask for external_directory permission when reading configured references", () =>
|
||||||
|
experimentalScout(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
|
||||||
|
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||||
|
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||||
|
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "opencode-read-reference")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
yield* put(path.join(source, "notes.md"), "reference notes")
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add notes"])
|
||||||
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
const dir = yield* tmpdirScoped({
|
||||||
|
git: true,
|
||||||
|
config: {
|
||||||
|
reference: {
|
||||||
|
docs: "opencode-read-reference/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { items, next } = asks()
|
||||||
|
const result = yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
exec(dir, { filePath: path.join(cache, "notes.md") }, next),
|
||||||
|
)
|
||||||
|
const ext = items.find((item) => item.permission === "external_directory")
|
||||||
|
|
||||||
|
expect(result.output).toContain("reference notes")
|
||||||
|
expect(ext).toBeUndefined()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("tool.read env file permissions", () => {
|
describe("tool.read env file permissions", () => {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { Format } from "@/format"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@/file/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"
|
||||||
|
|
||||||
const node = CrossSpawnSpawner.defaultLayer
|
const node = CrossSpawnSpawner.defaultLayer
|
||||||
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||||
|
|
@ -42,6 +43,7 @@ const registryLayer = ToolRegistry.layer.pipe(
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue