feat(scout): materialize configured reference repos (#26692)
This commit is contained in:
@@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
||||
@@ -20,6 +21,7 @@ export const GlobTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -39,11 +41,15 @@ export const GlobTool = Tool.define(
|
||||
|
||||
let search = params.path ?? ins.directory
|
||||
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)))
|
||||
if (info?.type === "File") {
|
||||
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
|
||||
let truncated = false
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
@@ -25,6 +26,7 @@ export const GrepTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -57,10 +59,12 @@ export const GrepTool = Tool.define(
|
||||
? (params.path ?? ins.directory)
|
||||
: path.join(ins.directory, params.path ?? "."),
|
||||
)
|
||||
yield* reference.ensure(search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
bypass: yield* reference.contains(search),
|
||||
kind: info?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -41,6 +42,7 @@ export const ReadTool = Tool.define(
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const reference = yield* Reference.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
||||
@@ -162,6 +164,7 @@ export const ReadTool = Tool.define(
|
||||
if (process.platform === "win32") {
|
||||
filepath = AppFileSystem.normalizePath(filepath)
|
||||
}
|
||||
yield* reference.ensure(filepath)
|
||||
const title = path.relative(instance.worktree, filepath)
|
||||
|
||||
const stat = yield* fs.stat(filepath).pipe(
|
||||
@@ -172,7 +175,7 @@ export const ReadTool = Tool.define(
|
||||
)
|
||||
|
||||
yield* assertExternalDirectoryEffect(ctx, filepath, {
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)),
|
||||
kind: stat?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Agent } from "../agent/agent"
|
||||
import { Git } from "@/git"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
@@ -91,6 +92,7 @@ export const layer: Layer.Layer<
|
||||
| Session.Service
|
||||
| Provider.Service
|
||||
| Git.Service
|
||||
| Reference.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
@@ -361,6 +363,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Git } from "@/git"
|
||||
import DESCRIPTION from "./repo_clone.txt"
|
||||
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({
|
||||
repository: Schema.String.annotate({
|
||||
@@ -29,36 +28,6 @@ type Metadata = {
|
||||
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>(
|
||||
"repo_clone",
|
||||
Effect.gen(function* () {
|
||||
@@ -70,16 +39,12 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = parseRepositoryReference(params.repository)
|
||||
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")
|
||||
if (params.branch) validateBranch(params.branch)
|
||||
const reference = parseRemoteRepositoryReference(params.repository)
|
||||
if (params.branch) validateRepositoryBranch(params.branch)
|
||||
|
||||
const repository = reference.label
|
||||
const remote = reference.remote
|
||||
const localPath = repositoryCachePath(reference)
|
||||
const cloneTarget = parseRepositoryReference(remote) ?? reference
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "repo_clone",
|
||||
@@ -94,115 +59,21 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
||||
},
|
||||
})
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
||||
() =>
|
||||
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),
|
||||
const result = yield* RepositoryCache.ensure(
|
||||
{ reference, refresh: params.refresh, branch: params.branch },
|
||||
{ fs, git },
|
||||
)
|
||||
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),
|
||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user