diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3f7604653c..5b1be7470a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -19,7 +19,7 @@ import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" import { Reference } from "@/reference/reference" -import type { Config } from "@/config/config" +import { ConfigReference } from "@/config/reference" import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display" function removeLineRange(input: string) { @@ -310,7 +310,7 @@ export function Autocomplete(props: { `Referenced configured reference @${reference.name}.`, ...(reference.kind === "local" ? ["Kind: local directory"] : []), ...(reference.kind === "git" ? ["Kind: git repository"] : []), - ...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []), + ...(reference.kind === "invalid" && reference.repository ? [`Repository: ${reference.repository}`] : []), ...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []), ...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []), ...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]), @@ -324,7 +324,7 @@ export function Autocomplete(props: { const references = createMemo(() => Reference.resolveAll({ - references: (sync.data.config.reference ?? {}) as NonNullable, + references: ConfigReference.normalize(sync.data.config.reference ?? {}), directory: sync.path.directory || process.cwd(), worktree: sync.path.worktree || sync.path.directory || process.cwd(), }), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index b3dec491ac..ddfe3f85a1 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -18,6 +18,52 @@ const Local = Schema.Struct({ }) export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" }) +export type Entry = Schema.Schema.Type export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" }) export type Info = Schema.Schema.Type + +export type NormalizedEntry = + | { + kind: "local" + path: string + } + | { + kind: "git" + repository: string + branch?: string + } + | { + kind: "invalid" + message: string + } + +export type NormalizedInfo = Record + +export function validateAlias(name: string) { + if (name.length === 0) return "Reference alias must not be empty" + if (/[\/\s`,]/.test(name)) { + return "Reference alias must not contain /, whitespace, comma, or backtick" + } +} + +export function normalizeEntry(entry: 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: Info): NormalizedInfo { + return Object.fromEntries( + Object.entries(info).map(([name, entry]) => { + const aliasError = validateAlias(name) + return [name, aliasError ? { kind: "invalid" as const, message: aliasError } : normalizeEntry(entry)] as const + }), + ) +} diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts index 3109c37492..e2f8ce218e 100644 --- a/packages/opencode/src/reference/reference.ts +++ b/packages/opencode/src/reference/reference.ts @@ -3,14 +3,13 @@ import { Effect, Context, Layer, Scope } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" 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 { Git } from "@/git" import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" import { RepositoryCache } from "./repository-cache" -type ReferenceEntry = NonNullable[string] - export type Resolved = | { name: string @@ -28,7 +27,7 @@ export type Resolved = | { name: string kind: "invalid" - repository: string + repository?: string message: string } @@ -92,26 +91,21 @@ function containsReferencePath(referencePath: string, target: string) { export function resolve(input: { name: string - reference: ReferenceEntry + reference: ConfigReference.NormalizedEntry 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 (input.reference.kind === "invalid") { + return { name: input.name, kind: "invalid", message: input.reference.message } } - - if ("path" in input.reference) { + 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: NonNullable + references: ConfigReference.NormalizedInfo directory: string worktree: string }) { @@ -149,7 +143,7 @@ export const layer = Layer.effect( Effect.fn("Reference.state")(function* (ctx) { const cfg = yield* config.get() const references = resolveAll({ - references: cfg.reference ?? {}, + references: ConfigReference.normalize(cfg.reference ?? {}), directory: ctx.directory, worktree: ctx.worktree, }) diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts index 8340a0c9fc..e3a4f76721 100644 --- a/packages/opencode/test/reference/reference.test.ts +++ b/packages/opencode/test/reference/reference.test.ts @@ -5,6 +5,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" import { Config } from "../../src/config/config" +import { ConfigReference } from "../../src/config/reference" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Git } from "../../src/git" import { Reference } from "../../src/reference/reference" @@ -86,25 +87,25 @@ describe("reference", () => { const root = path.resolve("opencode-reference-root") const local = Reference.resolve({ name: "docs", - reference: { path: "../docs" }, + reference: ConfigReference.normalizeEntry({ path: "../docs" }), directory: path.join(root, "packages", "app"), worktree: root, }) const repo = Reference.resolve({ name: "effect", - reference: { repository: "Effect-TS/effect", branch: "main" }, + reference: ConfigReference.normalizeEntry({ repository: "Effect-TS/effect", branch: "main" }), directory: path.join(root, "packages", "app"), worktree: root, }) const localString = Reference.resolve({ name: "notes", - reference: "./notes", + reference: ConfigReference.normalizeEntry("./notes"), directory: path.join(root, "packages", "app"), worktree: root, }) const repoString = Reference.resolve({ name: "repo", - reference: "owner/repo", + reference: ConfigReference.normalizeEntry("owner/repo"), directory: path.join(root, "packages", "app"), worktree: root, }) @@ -159,11 +160,11 @@ describe("reference", () => { const references = Reference.resolveAll({ directory: root, worktree: root, - references: { + references: ConfigReference.normalize({ 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"]) @@ -175,6 +176,27 @@ describe("reference", () => { }), ) + it.live("represents invalid aliases as invalid references", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const references = Reference.resolveAll({ + directory: root, + worktree: root, + references: ConfigReference.normalize({ + "bad/name": "owner/repo", + }), + }) + + expect(references).toEqual([ + { + name: "bad/name", + kind: "invalid", + message: "Reference alias must not contain /, whitespace, comma, or backtick", + }, + ]) + }), + ) + scout.live("materializes configured git references during init", () => provideTmpdirInstance( (_dir) =>