fix(core): skip saving or unarchiving deleted project directories (#60952)

This commit is contained in:
Kit Langton
2026-10-31 13:46:34 -07:00
committed by GitHub
Unverified
parent ed892fbd37
commit dc4775a64d
4 changed files with 112 additions and 4 deletions
+7 -2
View File
@@ -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],
})
+3 -1
View File
@@ -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)
}),
)
})
+31 -1
View File
@@ -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))
})