feat(core): split vcs backend selection
This commit is contained in:
parent
8ede176244
commit
ef1c76a2bf
6 changed files with 125 additions and 32 deletions
|
|
@ -10,6 +10,7 @@ export { Info, Ref, response }
|
||||||
|
|
||||||
export interface Interface extends Info {
|
export interface Interface extends Info {
|
||||||
readonly vcs?: Project.Vcs
|
readonly vcs?: Project.Vcs
|
||||||
|
readonly vcsBackend?: Project.Vcs
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||||
|
|
@ -27,6 +28,7 @@ const layer = (ref: Ref) =>
|
||||||
workspaceID: ref.workspaceID,
|
workspaceID: ref.workspaceID,
|
||||||
project: { id: resolved.id, directory: resolved.directory },
|
project: { id: resolved.id, directory: resolved.directory },
|
||||||
vcs: resolved.vcs,
|
vcs: resolved.vcs,
|
||||||
|
vcsBackend: resolved.vcsBackend,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,14 @@ export interface Resolved {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly vcs?: Vcs
|
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.
|
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
||||||
export const root = Effect.fn("Project.root")(function* (
|
export const root = Effect.fn("Project.root")(function* (
|
||||||
fs: FSUtil.Interface,
|
fs: FSUtil.Interface,
|
||||||
|
|
@ -198,16 +204,11 @@ const layer = Layer.effect(
|
||||||
Effect.catch(() => Effect.succeed(undefined)),
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
if (!dotHg) return 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 {
|
return {
|
||||||
previous,
|
type: "hg" as const,
|
||||||
id: id ?? ID.global,
|
directory: AbsolutePath.make(path.dirname(dotHg)),
|
||||||
directory: worktree,
|
vcs: { type: "hg", store: AbsolutePath.make(dotHg) },
|
||||||
vcs: { type: "hg" as const, store },
|
} satisfies Discovery
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const markerDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
const markerDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||||
|
|
@ -221,34 +222,67 @@ const layer = Layer.effect(
|
||||||
if (!match) return undefined
|
if (!match) return undefined
|
||||||
const type = types.get(path.basename(match))
|
const type = types.get(path.basename(match))
|
||||||
if (!type) return undefined
|
if (!type) return undefined
|
||||||
const store = AbsolutePath.make(match)
|
|
||||||
const previous = yield* cached(store)
|
|
||||||
return {
|
return {
|
||||||
previous,
|
type: "plugin" as const,
|
||||||
id: previous ?? ID.make(Hash.fast(`vcs-store:${store}`)),
|
|
||||||
directory: AbsolutePath.make(path.dirname(match)),
|
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 resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||||
const repo = yield* git.repo.discover(input)
|
const repo = yield* git.repo.discover(input)
|
||||||
if (repo) {
|
const [hg, marker] = yield* Effect.all([hgDiscover(input), markerDiscover(input)])
|
||||||
const previous = yield* cached(repo.commonDirectory)
|
const discoveries: Discovery[] = [
|
||||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
...(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 {
|
return {
|
||||||
previous,
|
previous,
|
||||||
id: id ?? ID.global,
|
id: id ?? ID.global,
|
||||||
directory: repo.worktree,
|
directory: selected.directory,
|
||||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
vcs: selected.vcs,
|
||||||
|
vcsBackend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hg = yield* hgDiscover(input)
|
const previous = yield* cached(selected.vcs.store)
|
||||||
if (hg) return hg
|
if (selected.type === "hg") {
|
||||||
const marker = yield* markerDiscover(input)
|
const id = previous ?? (yield* hgRoot(selected.directory))
|
||||||
if (marker) return marker
|
return {
|
||||||
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
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 }) {
|
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||||
|
|
|
||||||
|
|
@ -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 builtIn = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
||||||
const scope = { directory: location.directory, worktree: location.project.directory }
|
const scope = { directory: location.directory, worktree: location.project.directory }
|
||||||
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
|
if (location.vcsBackend?.type === "git") return VcsGit.make(proc, scope)
|
||||||
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
|
if (location.vcsBackend?.type === "hg") return VcsHg.make(proc, fs, scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
|
|
@ -42,11 +42,11 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const adapter = Effect.fnUntraced(function* () {
|
const adapter = Effect.fnUntraced(function* () {
|
||||||
if (native) return native
|
if (native) return native
|
||||||
if (!location.vcs) return undefined
|
if (!location.vcsBackend) return undefined
|
||||||
const plugin = backends.get(location.vcs.type)
|
const plugin = backends.get(location.vcsBackend.type)
|
||||||
if (!plugin && !warned) {
|
if (!plugin && !warned) {
|
||||||
warned = true
|
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
|
return plugin
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const layer = Layer.effect(
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => registry.delete(backend.type)))
|
yield* Effect.addFinalizer(() => Effect.sync(() => registry.delete(backend.type)))
|
||||||
}),
|
}),
|
||||||
get: (type) => {
|
get: (type) => {
|
||||||
const vcs = location.vcs
|
const vcs = location.vcsBackend
|
||||||
const entry = registry.get(type)
|
const entry = registry.get(type)
|
||||||
if (!entry || vcs?.type !== type) return undefined
|
if (!entry || vcs?.type !== type) return undefined
|
||||||
entry.adapter ??= guard(type, () =>
|
entry.adapter ??= guard(type, () =>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { tmpdir } from "./tmpdir"
|
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 {
|
return {
|
||||||
directory: ref.directory,
|
directory: ref.directory,
|
||||||
workspaceID: ref.workspaceID,
|
workspaceID: ref.workspaceID,
|
||||||
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
|
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
|
||||||
vcs: input.vcs,
|
vcs: input.vcs,
|
||||||
|
vcsBackend: input.vcsBackend ?? input.vcs,
|
||||||
} satisfies Location.Interface
|
} satisfies Location.Interface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,7 @@ describe("ProjectV2.resolve", () => {
|
||||||
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
||||||
|
|
||||||
expect(result.vcs?.type).toBe("hg")
|
expect(result.vcs?.type).toBe("hg")
|
||||||
|
expect(result.vcsBackend?.type).toBe("hg")
|
||||||
expect(result.directory).toBe(abs(tmp.path))
|
expect(result.directory).toBe(abs(tmp.path))
|
||||||
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
|
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
|
||||||
expect(result.previous).toBeUndefined()
|
expect(result.previous).toBeUndefined()
|
||||||
|
|
@ -297,6 +298,57 @@ describe("ProjectV2.resolve", () => {
|
||||||
const result = yield* project.resolve(abs(tmp.path))
|
const result = yield* project.resolve(abs(tmp.path))
|
||||||
|
|
||||||
expect(result.vcs?.type).toBe("git")
|
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")))
|
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
||||||
|
|
||||||
expect(result.vcs?.type).toBe("jj")
|
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.vcs?.store).toBe(abs(path.join(tmp.path, ".jj")))
|
||||||
expect(result.directory).toBe(abs(tmp.path))
|
expect(result.directory).toBe(abs(tmp.path))
|
||||||
expect(result.id).toBe(ProjectV2.ID.make(Hash.fast(`vcs-store:${path.join(tmp.path, ".jj")}`)))
|
expect(result.id).toBe(ProjectV2.ID.make(Hash.fast(`vcs-store:${path.join(tmp.path, ".jj")}`)))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue