diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 8ef532f488d4..41072e3ce3e0 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -2,7 +2,7 @@ export * as Snapshot from "./snapshot" import { makeLocationNode } from "./effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect" import { Config } from "./config" import { File } from "./file" import { FSUtil } from "./fs-util" @@ -91,36 +91,33 @@ const layer = Layer.effect( const git = yield* Git.Service const global = yield* Global.Service const location = yield* Location.Service - const source = yield* git.repo.discover(location.project.directory) - const worktree = source - ? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie)) - : location.project.directory - const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree))) + const lifetime = yield* Scope.Scope + // 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) + 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( + path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)), + ) + const snapshotRepository = (yield* fs.existsSafe(path.join(gitDirectory, "HEAD"))) + ? new Git.Repository({ worktree, gitDirectory, commonDirectory: gitDirectory }) + : yield* git.repo + .create({ worktree, gitDirectory, seed: source }) + .pipe(Effect.mapError((cause) => failure("capture", cause))) + return { source, worktree, snapshotRepository } + }).pipe(Effect.forkIn(lifetime)), + ) + const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join)) - const scope = Effect.fnUntraced(function* () { + const scope = Effect.fnUntraced(function* (worktree: AbsolutePath) { const relative = path.relative(worktree, location.directory) if (relative.startsWith("..") || path.isAbsolute(relative)) return yield* new Error({ operation: "capture", message: "Location is outside the project" }) return RelativePath.make(relative.replaceAll("\\", "/") || ".") }) - const repository = Effect.fnUntraced(function* () { - if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" }) - if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD"))) - return new Git.Repository({ - worktree, - gitDirectory, - commonDirectory: gitDirectory, - }) - return yield* git.repo - .create({ - worktree, - gitDirectory, - seed: source, - }) - .pipe(Effect.mapError((cause) => failure("capture", cause))) - }) - const enabled = Effect.fnUntraced(function* () { if (location.vcs?.type !== "git") return false return Config.latest(yield* config.entries(), "snapshots") !== false @@ -129,12 +126,12 @@ const layer = Layer.effect( const capture = Effect.fn("Snapshot.capture")(function* () { if (!(yield* enabled())) return undefined return yield* Effect.gen(function* () { - const repo = yield* repository() + const repo = yield* repository return ID.make( yield* git.tree.capture({ - repository: repo, - scopes: [yield* scope()], - ignores: source, + repository: repo.snapshotRepository, + scopes: [yield* scope(repo.worktree)], + ignores: repo.source, maximumUntrackedFileBytes: 2 * 1024 * 1024, }), ) @@ -144,38 +141,46 @@ const layer = Layer.effect( }) const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) { - const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause))) - return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) } + const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause))) + return { + source: repo.source, + input: { + repository: repo.snapshotRepository, + from: Git.TreeID.make(input.from), + to: Git.TreeID.make(input.to), + }, + } }) const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) { const comparison = yield* compare("files", input) - const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("files", cause))) - if (!source) return files + const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause))) const ignored = yield* git.index - .ignored({ repository: source, paths: files }) + .ignored({ repository: comparison.source, paths: files }) .pipe(Effect.mapError((cause) => failure("files", cause))) return files.filter((file) => !ignored.has(file)) }) const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) { const comparison = yield* compare("diff", input) - const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("diff", cause))) - const ignored = source - ? yield* git.index - .ignored({ repository: source, paths: files }) - .pipe(Effect.mapError((cause) => failure("diff", cause))) - : new Set() + const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause))) + const ignored = yield* git.index + .ignored({ repository: comparison.source, paths: files }) + .pipe(Effect.mapError((cause) => failure("diff", cause))) return yield* git.tree .diff({ - ...comparison, + ...comparison.input, context: input.context, paths: (input.paths ?? files).filter((file) => !ignored.has(file)), }) .pipe(Effect.mapError((cause) => failure("diff", cause))) }) - const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) { + const plan = Effect.fnUntraced(function* ( + operation: "preview" | "restore", + worktree: AbsolutePath, + input: RestoreInput, + ) { const files = new Map() for (const [file, snapshot] of input.files) { const absolute = path.resolve(worktree, file) @@ -188,19 +193,19 @@ const layer = Layer.effect( const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) { if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" }) - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause))) - const files = yield* plan("preview", input) + const repo = yield* repository.pipe(Effect.mapError((cause) => failure("preview", cause))) + const files = yield* plan("preview", repo.worktree, input) const current = yield* git.tree .capture({ - repository: repo, + repository: repo.snapshotRepository, scopes: Array.from(files.keys()), - ignores: source, + ignores: repo.source, maximumUntrackedFileBytes: 2 * 1024 * 1024, }) .pipe(Effect.mapError((cause) => failure("preview", cause))) return yield* git.tree .preview({ - repository: repo, + repository: repo.snapshotRepository, current, files, context: input.context, @@ -210,16 +215,16 @@ const layer = Layer.effect( const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) { if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" }) - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause))) yield* git.tree - .restore({ repository: repo, files: yield* plan("restore", input) }) + .restore({ repository: repo.snapshotRepository, files: yield* plan("restore", repo.worktree, input) }) .pipe(Effect.mapError((cause) => failure("restore", cause))) }) const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) { - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause))) yield* git.tree - .checkout({ repository: repo, tree: Git.TreeID.make(snapshot) }) + .checkout({ repository: repo.snapshotRepository, tree: Git.TreeID.make(snapshot) }) .pipe(Effect.mapError((cause) => failure("restore", cause))) }) diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 3bc0f341013e..19966e9d29a7 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -2,8 +2,9 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Fiber, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Git } from "@opencode-ai/core/git" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" @@ -13,6 +14,80 @@ 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", () => + 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 $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + }) + + const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node))) + const location = yield* Location.Service.pipe( + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), + ) + const started = yield* Deferred.make() + const release = yield* Deferred.make() + let discoveries = 0 + let creations = 0 + const instrumented = Git.Service.of({ + ...git, + repo: { + ...git.repo, + discover: (input) => { + discoveries++ + return git.repo.discover(input) + }, + create: (input) => + Effect.gen(function* () { + creations++ + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + return yield* git.repo.create(input) + }), + }, + }) + const layer = AppNodeBuilder.build(Snapshot.node, [ + [Location.node, Layer.succeed(Location.Service, location)], + [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })], + [Git.node, Layer.succeed(Git.Service, instrumented)], + ]) + + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(discoveries).toBe(0) + + const interrupted = yield* snapshot.capture().pipe(Effect.forkChild) + yield* Deferred.await(started) + expect(discoveries).toBe(1) + expect(creations).toBe(1) + yield* Fiber.interrupt(interrupted) + + const capture = yield* snapshot.capture().pipe(Effect.forkChild) + expect(discoveries).toBe(1) + expect(creations).toBe(1) + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(capture)).toBeDefined() + expect(discoveries).toBe(1) + expect(creations).toBe(1) + }).pipe(Effect.provide(layer)) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + testEffect(Layer.empty).live("captures and restores Location-scoped changes", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()),