Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline ab3ef000cb test(core): cover snapshot discovery fallback 2026-07-22 10:40:50 -05:00
Aiden Cline d54d26acd6 fix(core): reuse location git repository 2026-07-22 10:29:39 -05:00
5 changed files with 75 additions and 5 deletions
+3
View File
@@ -1,6 +1,7 @@
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Project } from "./project"
import { Git } from "./git"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
@@ -10,6 +11,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly repository?: Git.Repository
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +29,7 @@ const layer = (ref: Ref) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
repository: resolved.repository,
})
}),
)
+2
View File
@@ -42,6 +42,7 @@ export interface Resolved {
readonly id: ID
readonly directory: AbsolutePath
readonly vcs?: Vcs
readonly repository?: Git.Repository
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
@@ -217,6 +218,7 @@ const layer = Layer.effect(
id: id ?? ID.global,
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
repository: repo,
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ const layer = Layer.effect(
// Cache a scope-owned fiber so caller cancellation stops waiting without poisoning shared initialization.
const repositoryFiber = yield* Effect.cached(
Effect.gen(function* () {
const source = yield* git.repo.discover(location.project.directory)
const source = location.repository ?? (yield* git.repo.discover(location.project.directory))
if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" })
const worktree = AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie))
const gitDirectory = AbsolutePath.make(
+8
View File
@@ -3,12 +3,18 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
const repository = new Git.Repository({
worktree: AbsolutePath.make("/repo"),
gitDirectory: AbsolutePath.make("/repo/.git"),
commonDirectory: AbsolutePath.make("/repo/.git"),
})
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
@@ -19,6 +25,7 @@ const projectLayer = Layer.succeed(
id: Project.ID.make("project"),
directory: AbsolutePath.make("/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
repository,
}),
commit: () => Effect.void,
}),
@@ -38,6 +45,7 @@ describe("Location", () => {
type: "git",
store: AbsolutePath.make("/repo/.git"),
})
expect(location.repository).toBe(repository)
}),
)
})
+61 -4
View File
@@ -7,6 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Git } from "@opencode-ai/core/git"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Hash } from "@opencode-ai/util/hash"
@@ -14,7 +15,7 @@ import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
describe("Snapshot", () => {
testEffect(Layer.empty).live("keeps lazy repository discovery after the first caller is interrupted", () =>
testEffect(Layer.empty).live("reuses the Location repository after the first caller is interrupted", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -65,16 +66,16 @@ describe("Snapshot", () => {
const interrupted = yield* snapshot.capture().pipe(Effect.forkChild)
yield* Deferred.await(started)
expect(discoveries).toBe(1)
expect(discoveries).toBe(0)
expect(creations).toBe(1)
yield* Fiber.interrupt(interrupted)
const capture = yield* snapshot.capture().pipe(Effect.forkChild)
expect(discoveries).toBe(1)
expect(discoveries).toBe(0)
expect(creations).toBe(1)
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(capture)).toBeDefined()
expect(discoveries).toBe(1)
expect(discoveries).toBe(0)
expect(creations).toBe(1)
}).pipe(Effect.provide(layer))
}),
@@ -82,6 +83,62 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("discovers the repository for a manually supplied Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await initGit(project)
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
let discoveries = 0
const layer = AppNodeBuilder.build(Snapshot.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of({
directory: AbsolutePath.make(project),
project: { id: Project.ID.global, directory: AbsolutePath.make(project) },
vcs: { type: "git", store: AbsolutePath.make(path.join(project, ".git")) },
}),
),
],
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
[
Git.node,
Layer.succeed(
Git.Service,
Git.Service.of({
...git,
repo: {
...git.repo,
discover: (input) => {
discoveries++
return git.repo.discover(input)
},
},
}),
),
],
])
expect(
yield* Effect.gen(function* () {
return yield* (yield* Snapshot.Service).capture()
}).pipe(Effect.provide(layer)),
).toBeDefined()
expect(discoveries).toBe(1)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),