fix(core): defer snapshot repository discovery (#36290)
This commit is contained in:
@@ -2,7 +2,7 @@ export * as Snapshot from "./snapshot"
|
|||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import path from "path"
|
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 { Config } from "./config"
|
||||||
import { File } from "./file"
|
import { File } from "./file"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
@@ -91,36 +91,33 @@ const layer = Layer.effect(
|
|||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const source = yield* git.repo.discover(location.project.directory)
|
const lifetime = yield* Scope.Scope
|
||||||
const worktree = source
|
// Cache a scope-owned fiber so caller cancellation stops waiting without poisoning shared initialization.
|
||||||
? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie))
|
const repositoryFiber = yield* Effect.cached(
|
||||||
: location.project.directory
|
Effect.gen(function* () {
|
||||||
const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)))
|
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)
|
const relative = path.relative(worktree, location.directory)
|
||||||
if (relative.startsWith("..") || path.isAbsolute(relative))
|
if (relative.startsWith("..") || path.isAbsolute(relative))
|
||||||
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
|
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
|
||||||
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
|
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* () {
|
const enabled = Effect.fnUntraced(function* () {
|
||||||
if (location.vcs?.type !== "git") return false
|
if (location.vcs?.type !== "git") return false
|
||||||
return Config.latest(yield* config.entries(), "snapshots") !== false
|
return Config.latest(yield* config.entries(), "snapshots") !== false
|
||||||
@@ -129,12 +126,12 @@ const layer = Layer.effect(
|
|||||||
const capture = Effect.fn("Snapshot.capture")(function* () {
|
const capture = Effect.fn("Snapshot.capture")(function* () {
|
||||||
if (!(yield* enabled())) return undefined
|
if (!(yield* enabled())) return undefined
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
const repo = yield* repository()
|
const repo = yield* repository
|
||||||
return ID.make(
|
return ID.make(
|
||||||
yield* git.tree.capture({
|
yield* git.tree.capture({
|
||||||
repository: repo,
|
repository: repo.snapshotRepository,
|
||||||
scopes: [yield* scope()],
|
scopes: [yield* scope(repo.worktree)],
|
||||||
ignores: source,
|
ignores: repo.source,
|
||||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -144,38 +141,46 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||||
const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause)))
|
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) }
|
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 files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||||
const comparison = yield* compare("files", input)
|
const comparison = yield* compare("files", input)
|
||||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("files", cause)))
|
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||||
if (!source) return files
|
|
||||||
const ignored = yield* git.index
|
const ignored = yield* git.index
|
||||||
.ignored({ repository: source, paths: files })
|
.ignored({ repository: comparison.source, paths: files })
|
||||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||||
return files.filter((file) => !ignored.has(file))
|
return files.filter((file) => !ignored.has(file))
|
||||||
})
|
})
|
||||||
|
|
||||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||||
const comparison = yield* compare("diff", input)
|
const comparison = yield* compare("diff", input)
|
||||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("diff", cause)))
|
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||||
const ignored = source
|
const ignored = yield* git.index
|
||||||
? yield* git.index
|
.ignored({ repository: comparison.source, paths: files })
|
||||||
.ignored({ repository: source, paths: files })
|
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
|
||||||
: new Set<RelativePath>()
|
|
||||||
return yield* git.tree
|
return yield* git.tree
|
||||||
.diff({
|
.diff({
|
||||||
...comparison,
|
...comparison.input,
|
||||||
context: input.context,
|
context: input.context,
|
||||||
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
|
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
|
||||||
})
|
})
|
||||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
.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<RelativePath, Git.TreeID>()
|
const files = new Map<RelativePath, Git.TreeID>()
|
||||||
for (const [file, snapshot] of input.files) {
|
for (const [file, snapshot] of input.files) {
|
||||||
const absolute = path.resolve(worktree, file)
|
const absolute = path.resolve(worktree, file)
|
||||||
@@ -188,19 +193,19 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
||||||
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
|
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 repo = yield* repository.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||||
const files = yield* plan("preview", input)
|
const files = yield* plan("preview", repo.worktree, input)
|
||||||
const current = yield* git.tree
|
const current = yield* git.tree
|
||||||
.capture({
|
.capture({
|
||||||
repository: repo,
|
repository: repo.snapshotRepository,
|
||||||
scopes: Array.from(files.keys()),
|
scopes: Array.from(files.keys()),
|
||||||
ignores: source,
|
ignores: repo.source,
|
||||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||||
})
|
})
|
||||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||||
return yield* git.tree
|
return yield* git.tree
|
||||||
.preview({
|
.preview({
|
||||||
repository: repo,
|
repository: repo.snapshotRepository,
|
||||||
current,
|
current,
|
||||||
files,
|
files,
|
||||||
context: input.context,
|
context: input.context,
|
||||||
@@ -210,16 +215,16 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
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
|
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)))
|
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
|
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
|
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)))
|
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { $ } from "bun"
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Git } from "@opencode-ai/core/git"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
@@ -13,6 +14,80 @@ import { tmpdir } from "./fixture/tmpdir"
|
|||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
describe("Snapshot", () => {
|
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<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
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", () =>
|
testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
Reference in New Issue
Block a user