diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 8228b8599e..b3e71b6c8f 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -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()("@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, }) }), ) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 2a4fe89778..07b6235fb9 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -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 }) { diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index fcf1aece0c..4b30ea285e 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -26,8 +26,8 @@ export class Service extends Context.Service()("@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 }) diff --git a/packages/core/src/vcs/backends.ts b/packages/core/src/vcs/backends.ts index f8d47ec712..80328a0efe 100644 --- a/packages/core/src/vcs/backends.ts +++ b/packages/core/src/vcs/backends.ts @@ -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, () => diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 40d8ed9dc3..cee26684df 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -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 } diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index af1255f426..882fabed6d 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -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")}`)))