effectify Project service (#18808)

This commit is contained in:
Kit Langton
2026-03-24 14:04:22 -04:00
committed by GitHub
parent 814a515a8a
commit 539b01f20f
4 changed files with 578 additions and 456 deletions

View File

@@ -1,78 +1,69 @@
import { describe, expect, mock, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Project } from "../../src/project/project"
import { Log } from "../../src/util/log"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Filesystem } from "../../src/util/filesystem"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false })
const gitModule = await import("../../src/util/git")
const originalGit = gitModule.git
const encoder = new TextEncoder()
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
let mode: Mode = "none"
mock.module("../../src/util/git", () => ({
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
const cmd = ["git", ...args].join(" ")
if (
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("HEAD")
) {
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
return originalGit(args, opts)
},
}))
async function withMode(next: Mode, run: () => Promise<void>) {
const prev = mode
mode = next
try {
await run()
} finally {
mode = prev
}
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
* else to the real CrossSpawnSpawner.
*/
function mockGitFailure(failArg: string) {
return Layer.effect(
ChildProcessSpawner.ChildProcessSpawner,
Effect.gen(function* () {
const real = yield* ChildProcessSpawner.ChildProcessSpawner
return ChildProcessSpawner.make(
Effect.fnUntraced(function* (command) {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
if (std?.command === "git" && std.args.some((a) => a === failArg)) {
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: Stream.empty,
stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
})
}
return yield* real.spawn(command)
}),
)
}),
).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}
async function loadProject() {
return (await import("../../src/project/project")).Project
function projectLayerWithFailure(failArg: string) {
return Project.layer.pipe(
Layer.provide(mockGitFailure(failArg)),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
}
describe("Project.fromDirectory", () => {
test("should handle git repository with no commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
const { project } = await p.fromDirectory(tmp.path)
const { project } = await Project.fromDirectory(tmp.path)
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
@@ -80,15 +71,13 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Filesystem.exists(opencodeFile)
expect(fileExists).toBe(false)
expect(await Bun.file(opencodeFile).exists()).toBe(false)
})
test("should handle git repository with commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path)
const { project } = await Project.fromDirectory(tmp.path)
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
@@ -96,54 +85,63 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Filesystem.exists(opencodeFile)
expect(fileExists).toBe(true)
expect(await Bun.file(opencodeFile).exists()).toBe(true)
})
test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
const p = await loadProject()
test("returns global for non-git directory", async () => {
await using tmp = await tmpdir()
const { project } = await Project.fromDirectory(tmp.path)
expect(project.id).toBe(ProjectID.global)
})
test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true })
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(tmp.path)
expect(b.id).toBe(a.id)
})
})
describe("Project.fromDirectory git failure paths", () => {
test("keeps vcs when rev-list exits non-zero (no commits)", async () => {
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
await withMode("rev-list-fail", async () => {
const { project } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path)
})
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario
const { project } = await Project.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path)
})
test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => {
const p = await loadProject()
test("handles show-toplevel failure gracefully", async () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel")
await withMode("top-fail", async () => {
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => {
const p = await loadProject()
test("handles git-common-dir failure gracefully", async () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir")
await withMode("common-dir-fail", async () => {
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
})
describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await p.fromDirectory(tmp.path)
const { project, sandbox } = await Project.fromDirectory(tmp.path)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
@@ -151,14 +149,13 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("should set worktree to root when called from a worktree", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const { project, sandbox } = await p.fromDirectory(worktreePath)
const { project, sandbox } = await Project.fromDirectory(worktreePath)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath)
@@ -173,22 +170,21 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("worktree should share project ID with main repo", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project: main } = await p.fromDirectory(tmp.path)
const { project: main } = await Project.fromDirectory(tmp.path)
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await p.fromDirectory(worktreePath)
const { project: wt } = await Project.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id)
// Cache should live in the common .git dir, not the worktree's .git file
const cache = path.join(tmp.path, ".git", "opencode")
const exists = await Filesystem.exists(cache)
const exists = await Bun.file(cache).exists()
expect(exists).toBe(true)
} finally {
await $`git worktree remove ${worktreePath}`
@@ -199,7 +195,6 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("separate clones of the same repo should share project ID", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
// Create a bare remote, push, then clone into a second directory
@@ -209,8 +204,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await p.fromDirectory(tmp.path)
const { project: b } = await p.fromDirectory(clone)
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(clone)
expect(b.id).toBe(a.id)
} finally {
@@ -219,7 +214,6 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
@@ -228,8 +222,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await p.fromDirectory(worktree1)
const { project } = await p.fromDirectory(worktree2)
await Project.fromDirectory(worktree1)
const { project } = await Project.fromDirectory(worktree2)
expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1)
@@ -250,14 +244,13 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => {
test("should discover favicon.png in root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path)
const { project } = await Project.fromDirectory(tmp.path)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await p.discover(project)
await Project.discover(project)
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -268,13 +261,12 @@ describe("Project.discover", () => {
})
test("should not discover non-image files", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path)
const { project } = await Project.fromDirectory(tmp.path)
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await p.discover(project)
await Project.discover(project)
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -344,8 +336,6 @@ describe("Project.update", () => {
})
test("should throw error when project not found", async () => {
await using tmp = await tmpdir({ git: true })
await expect(
Project.update({
projectID: ProjectID.make("nonexistent-project-id"),
@@ -358,22 +348,22 @@ describe("Project.update", () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
let eventFired = false
let eventPayload: any = null
const on = (data: any) => { eventPayload = data }
GlobalBus.on("event", on)
GlobalBus.on("event", (data) => {
eventFired = true
eventPayload = data
})
try {
await Project.update({
projectID: project.id,
name: "Updated Name",
})
await Project.update({
projectID: project.id,
name: "Updated Name",
})
expect(eventFired).toBe(true)
expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name")
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name")
} finally {
GlobalBus.off("event", on)
}
})
test("should update multiple fields at once", async () => {
@@ -393,3 +383,75 @@ describe("Project.update", () => {
expect(updated.commands?.start).toBe("make start")
})
})
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
})
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const found = Project.get(project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
})
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
expect(found).toBeUndefined()
})
})
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
expect(project.time.initialized).toBeUndefined()
Project.setInitialized(project.id)
const updated = Project.get(project.id)
expect(updated?.time.initialized).toBeDefined()
})
})
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir)
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir)
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
})
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir)
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})