feat(core): split vcs backend selection

This commit is contained in:
Shoubhit Dash 2026-07-06 18:19:13 +05:30
commit ef1c76a2bf
6 changed files with 125 additions and 32 deletions

View file

@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: Project.Vcs
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@ -27,6 +28,7 @@ const layer = (ref: Ref) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)

View file

@ -44,8 +44,14 @@ export interface Resolved {
readonly id: ID
readonly directory: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: Vcs
}
type Discovery =
| { readonly type: "git"; readonly directory: AbsolutePath; readonly vcs: Vcs; readonly repo: Git.Repository }
| { readonly type: "hg"; readonly directory: AbsolutePath; readonly vcs: Vcs }
| { readonly type: "plugin"; readonly directory: AbsolutePath; readonly vcs: Vcs }
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
@ -198,16 +204,11 @@ const layer = Layer.effect(
Effect.catch(() => Effect.succeed(undefined)),
)
if (!dotHg) return undefined
const worktree = AbsolutePath.make(path.dirname(dotHg))
const store = AbsolutePath.make(dotHg)
const previous = yield* cached(store)
const id = previous ?? (yield* hgRoot(worktree))
return {
previous,
id: id ?? ID.global,
directory: worktree,
vcs: { type: "hg" as const, store },
}
type: "hg" as const,
directory: AbsolutePath.make(path.dirname(dotHg)),
vcs: { type: "hg", store: AbsolutePath.make(dotHg) },
} satisfies Discovery
})
const markerDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
@ -221,34 +222,67 @@ const layer = Layer.effect(
if (!match) return undefined
const type = types.get(path.basename(match))
if (!type) return undefined
const store = AbsolutePath.make(match)
const previous = yield* cached(store)
return {
previous,
id: previous ?? ID.make(Hash.fast(`vcs-store:${store}`)),
type: "plugin" as const,
directory: AbsolutePath.make(path.dirname(match)),
vcs: { type, store },
}
vcs: { type, store: AbsolutePath.make(match) },
} satisfies Discovery
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.repo.discover(input)
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const [hg, marker] = yield* Effect.all([hgDiscover(input), markerDiscover(input)])
const discoveries: Discovery[] = [
...(repo
? [
{
type: "git" as const,
directory: repo.worktree,
vcs: { type: "git", store: repo.commonDirectory },
repo,
} satisfies Discovery,
]
: []),
...(hg ? [hg] : []),
...(marker ? [marker] : []),
]
const distance = (directory: AbsolutePath) =>
path.relative(directory, input).split(path.sep).filter(Boolean).length
const selected = discoveries.toSorted((a, b) => distance(a.directory) - distance(b.directory))[0]
if (!selected) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const vcsBackend = marker?.directory === selected.directory ? marker.vcs : selected.vcs
if (selected.type === "git") {
const previous = yield* cached(selected.repo.commonDirectory)
const id = (yield* remote(selected.repo)) ?? previous ?? (yield* root(selected.repo))
return {
previous,
id: id ?? ID.global,
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
directory: selected.directory,
vcs: selected.vcs,
vcsBackend,
}
}
const hg = yield* hgDiscover(input)
if (hg) return hg
const marker = yield* markerDiscover(input)
if (marker) return marker
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const previous = yield* cached(selected.vcs.store)
if (selected.type === "hg") {
const id = previous ?? (yield* hgRoot(selected.directory))
return {
previous,
id: id ?? ID.global,
directory: selected.directory,
vcs: selected.vcs,
vcsBackend,
}
}
return {
previous,
id: previous ?? ID.make(Hash.fast(`vcs-store:${selected.vcs.store}`)),
directory: selected.directory,
vcs: selected.vcs,
vcsBackend,
}
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {

View file

@ -26,8 +26,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const builtIn = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
const scope = { directory: location.directory, worktree: location.project.directory }
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
if (location.vcsBackend?.type === "git") return VcsGit.make(proc, scope)
if (location.vcsBackend?.type === "hg") return VcsHg.make(proc, fs, scope)
}
const layer = Layer.effect(
@ -42,11 +42,11 @@ const layer = Layer.effect(
const adapter = Effect.fnUntraced(function* () {
if (native) return native
if (!location.vcs) return undefined
const plugin = backends.get(location.vcs.type)
if (!location.vcsBackend) return undefined
const plugin = backends.get(location.vcsBackend.type)
if (!plugin && !warned) {
warned = true
yield* Effect.logWarning("vcs backend declared but not registered", { type: location.vcs.type })
yield* Effect.logWarning("vcs backend declared but not registered", { type: location.vcsBackend.type })
}
return plugin
})

View file

@ -50,7 +50,7 @@ const layer = Layer.effect(
yield* Effect.addFinalizer(() => Effect.sync(() => registry.delete(backend.type)))
}),
get: (type) => {
const vcs = location.vcs
const vcs = location.vcsBackend
const entry = registry.get(type)
if (!entry || vcs?.type !== type) return undefined
entry.adapter ??= guard(type, () =>

View file

@ -4,12 +4,16 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
export function location(
ref: Location.Ref,
input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs; vcsBackend?: Project.Vcs } = {},
) {
return {
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
vcsBackend: input.vcsBackend ?? input.vcs,
} satisfies Location.Interface
}

View file

@ -278,6 +278,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
expect(result.vcs?.type).toBe("hg")
expect(result.vcsBackend?.type).toBe("hg")
expect(result.directory).toBe(abs(tmp.path))
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
expect(result.previous).toBeUndefined()
@ -297,6 +298,57 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend?.type).toBe("git")
}),
)
itMarker.live("uses a plugin backend without replacing colocated git repository identity", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await Bun.write(
path.join(globalConfig.path, "opencode.json"),
JSON.stringify({ vcs: { jj: { marker: ".jj" } } }),
)
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".jj"))
})
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend?.type).toBe("jj")
expect(result.directory).toBe(yield* real(tmp.path))
}),
)
itMarker.live("prefers a nested plugin repository over an ancestor git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await Bun.write(
path.join(globalConfig.path, "opencode.json"),
JSON.stringify({ vcs: { jj: { marker: ".jj" } } }),
)
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(nested, ".jj"), { recursive: true })
await fs.mkdir(path.join(nested, "src"))
})
const project = yield* ProjectV2.Service
const result = yield* project.resolve(abs(path.join(nested, "src")))
expect(result.vcs?.type).toBe("jj")
expect(result.vcsBackend?.type).toBe("jj")
expect(result.directory).toBe(abs(nested))
}),
)
@ -319,6 +371,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
expect(result.vcs?.type).toBe("jj")
expect(result.vcsBackend?.type).toBe("jj")
expect(result.vcs?.store).toBe(abs(path.join(tmp.path, ".jj")))
expect(result.directory).toBe(abs(tmp.path))
expect(result.id).toBe(ProjectV2.ID.make(Hash.fast(`vcs-store:${path.join(tmp.path, ".jj")}`)))