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 {
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, () =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}`)))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue