mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
fix(core): skip saving or unarchiving deleted project directories (#60952)
This commit is contained in:
committed by
GitHub
Unverified
parent
ed892fbd37
commit
dc4775a64d
@@ -1,5 +1,6 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Info, Ref, response } from "@opencode/schema/location"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Project } from "./project.js"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { makeLocationNode, tags } from "@opencode/util/effect/app-node"
|
||||
@@ -20,8 +21,12 @@ const layer = (ref: Ref) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const project = yield* Project.Service
|
||||
const resolved = yield* project.resolve(ref.directory)
|
||||
// Location.node builds alongside FileSystem.node, so avoid creating or unarchiving
|
||||
// a Project row for a local path that FileSystem.node is about to reject.
|
||||
const persist = ref.workspaceID !== undefined || (yield* fs.isDir(ref.directory))
|
||||
const resolved = yield* project.resolve(ref.directory, { persist })
|
||||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
@@ -35,5 +40,5 @@ export const boundNode = (ref: Ref) =>
|
||||
makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(ref),
|
||||
deps: [Project.node],
|
||||
deps: [FSUtil.node, Project.node],
|
||||
})
|
||||
|
||||
@@ -55,7 +55,9 @@ export const refresh = Effect.fn("ProjectIcon.refresh")(function* (
|
||||
})
|
||||
|
||||
export const discover = Effect.fn("ProjectIcon.discover")(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const root = yield* fs.realPath(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!resolved) return
|
||||
const root = resolved
|
||||
const children = yield* fs.readDirectoryEntries(root).pipe(Effect.orElseSucceed(() => []))
|
||||
const catalogs = children
|
||||
.filter((e) => e.type === "Directory" && !e.name.startsWith("."))
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { LocationServiceMap } from "@opencode/core/location-services"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Project.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
|
||||
const load = (directory: AbsolutePath) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory }))),
|
||||
Effect.scoped,
|
||||
)
|
||||
})
|
||||
|
||||
describe("Location missing project persistence", () => {
|
||||
it.live("does not persist a project row when a local directory is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const missing = AbsolutePath.make(path.join(dir.path, "deleted-worktree"))
|
||||
const projects = yield* Project.Service
|
||||
|
||||
expect(Exit.isFailure(yield* Effect.exit(load(missing)))).toBe(true)
|
||||
expect((yield* projects.list({ archived: true })).some((item) => item.canonical === missing)).toBe(false)
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(missing))
|
||||
const resolved = yield* load(missing)
|
||||
expect(resolved.project.canonical).toBe(missing)
|
||||
expect((yield* projects.list({ archived: true })).some((item) => item.id === resolved.project.id)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not unarchive a project when its removed directory is requested", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const removed = AbsolutePath.make(path.join(dir.path, "removed-project"))
|
||||
yield* Effect.promise(() => fs.mkdir(removed))
|
||||
const db = (yield* Database.Service).db
|
||||
const projects = yield* Project.Service
|
||||
const created = yield* projects.resolve(removed)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(removed, { recursive: true, force: true }))
|
||||
yield* db.update(ProjectTable).set({ time_archived: 1 }).where(eq(ProjectTable.id, created.id)).run()
|
||||
|
||||
expect(Exit.isFailure(yield* Effect.exit(load(removed)))).toBe(true)
|
||||
const after = (yield* projects.list({ archived: true })).find((item) => item.id === created.id)
|
||||
expect(after?.time.archived).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { Effect, Exit, Fiber, Stream, Tracer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Project } from "@opencode/core/project"
|
||||
@@ -166,3 +166,33 @@ it.live("Composer backgrounds stay inert and malformed, oversized or escaping ma
|
||||
expect(yield* ProjectIcon.discover(filesystem, tmp.path)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns undefined without recording span failures when the directory is missing", () => {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const missing = path.join(tmp.path, "missing")
|
||||
const filesystem = yield* FSUtil.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const id = Project.ID.make("missing-icons")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id, worktree: AbsolutePath.make(missing), sandboxes: [] })
|
||||
.run()
|
||||
|
||||
expect(yield* ProjectIcon.refresh(filesystem, db, id)).toBeUndefined()
|
||||
|
||||
const iconSpans = spans.filter((span) => span.name.startsWith("ProjectIcon."))
|
||||
expect(iconSpans.map((span) => span.name)).toEqual(["ProjectIcon.refresh", "ProjectIcon.discover"])
|
||||
for (const span of iconSpans) {
|
||||
expect(span.status._tag === "Ended" && Exit.isSuccess(span.status.exit)).toBe(true)
|
||||
}
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user