feat(project): resolve remote-backed project identity (#28914)

This commit is contained in:
Dax 2026-05-23 00:48:09 -04:00 committed by GitHub
parent 0cf9a5d20b
commit a9ef5a0fae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 613 additions and 126 deletions

View file

@ -2,7 +2,8 @@ import { and } from "drizzle-orm"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { PermissionTable, SessionTable } from "../session/session.sql"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { BusEvent } from "@/bus/bus-event"
@ -12,12 +13,13 @@ import { ProjectID } from "./schema"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
@ -86,6 +88,10 @@ export function fromRow(row: Row): Info {
}
}
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
}
export const UpdateInput = Schema.Struct({
projectID: ProjectID,
name: Schema.optional(Schema.String),
@ -132,16 +138,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service | RuntimeFlags.Service
> = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const proc = yield* AppProcess.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const projectV2 = yield* ProjectV2.Service
const bus = yield* Bus.Service
const flags = yield* RuntimeFlags.Service
@ -175,115 +178,71 @@ export const layer: Layer.Layer<
const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS)
const resolveGitPath = (cwd: string, name: string) => {
if (!name) return cwd
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = AppFileSystem.windowsPath(name)
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
const scope = yield* Scope.Scope
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map((x) => ProjectID.make(x)),
Effect.catch(() => Effect.void),
const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (oldID: ProjectID | undefined, newID: ProjectID) {
if (!oldID) return
if (oldID === ProjectID.global) return
if (oldID === newID) return
yield* Effect.sync(() =>
Database.transaction(
(d) => {
const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
if (oldProject && !newProject) {
d.insert(ProjectTable)
.values({
...oldProject,
id: newID,
time_updated: Date.now(),
})
.run()
}
const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
if (oldPermission && newPermission) {
d.update(PermissionTable)
.set({
data: mergePermissionRules(oldPermission.data, newPermission.data),
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
time_updated: Date.now(),
})
.where(eq(PermissionTable.project_id, newID))
.run()
d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
}
if (oldPermission && !newPermission) {
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
}
d.update(SessionTable).set({ project_id: newID }).where(eq(SessionTable.project_id, oldID)).run()
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
},
{ behavior: "immediate" },
),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: fakeVcs,
}
}
let sandbox = pathSvc.dirname(dotgit)
const gitBinary = yield* Effect.sync(() => which("git"))
let id = yield* readCachedProjectId(dotgit)
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const common = resolveGitPath(sandbox, commonDir.text.trim())
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
if (id == null) {
id = yield* readCachedProjectId(common)
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fs.writeFileString(pathSvc.join(common, "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
})
const data = yield* projectV2.resolve(AbsolutePath.make(directory))
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory
// Phase 2: upsert
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const projectID = ProjectID.make(data.id)
yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID)
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
id: projectID,
worktree,
vcs: data.vcs?.type ?? fakeVcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
@ -292,12 +251,12 @@ export const layer: Layer.Layer<
const result: Info = {
...existing,
worktree: data.worktree,
vcs: data.vcs,
worktree: projectID === ProjectID.global ? worktree : existing.worktree,
vcs: data.vcs?.type ?? fakeVcs,
time: { ...existing.time, updated: Date.now() },
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
if (projectID !== ProjectID.global && data.directory !== result.worktree && !result.sandboxes.includes(data.directory))
result.sandboxes.push(data.directory)
result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
@ -343,18 +302,21 @@ export const layer: Layer.Layer<
.run(),
)
if (data.id !== ProjectID.global) {
if (projectID !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.set({ project_id: projectID })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory)))
.run(),
)
}
yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
}
return { project: result, sandbox: data.vcs ? data.directory : worktree }
})
const discover = Effect.fn("Project.discover")(function* (input: Info) {
@ -510,9 +472,10 @@ export const layer: Layer.Layer<
export const defaultLayer = layer.pipe(
Layer.provide(Bus.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)

View file

@ -7,10 +7,21 @@ import path from "path"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Database } from "@/storage/db"
import { ProjectTable } from "@/project/project.sql"
import { SessionTable } from "@/session/session.sql"
import { PermissionTable } from "@/session/session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
@ -29,6 +40,10 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
})
}
function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
}
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
@ -66,7 +81,9 @@ function mockGitFailure(failArg: string) {
function projectLayerWithFailure(failArg: string) {
return Project.layer.pipe(
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
Layer.provide(mockGitFailure(failArg)),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(Bus.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
@ -77,6 +94,8 @@ function projectLayerWithFailure(failArg: string) {
function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.layer>[0]) {
return Project.layer.pipe(
Layer.provide(Bus.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
Layer.provide(RuntimeFlags.layer(flags)),
@ -128,9 +147,6 @@ describe("Project.fromDirectory", () => {
expect(project.id).not.toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
const opencodeFile = path.join(tmp, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true)
}),
)
@ -150,6 +166,85 @@ describe("Project.fromDirectory", () => {
expect(b.id).toBe(a.id)
}),
)
it.live("prefers normalized origin remote over root commit", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
}),
)
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
Effect.gen(function* () {
const ssh = yield* tmpdirScoped({ git: true })
const https = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("migrates cached root project data when origin becomes available", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project: rootProject } = yield* projects.fromDirectory(tmp)
const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceID.ascending()
yield* Effect.sync(() => {
Database.use((db) => {
db.insert(SessionTable)
.values({
id: sessionID,
project_id: rootProject.id,
slug: sessionID,
directory: tmp,
title: "test",
version: "0.0.0-test",
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(PermissionTable)
.values({
project_id: rootProject.id,
data: [{ permission: "edit", pattern: "*", action: "allow" }],
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(WorkspaceTable)
.values({
id: workspaceID,
type: "local",
name: "test",
project_id: rootProject.id,
})
.run()
})
})
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).toBe(remoteID)
expect(Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get())).toBeUndefined()
expect(Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id).toBe(remoteID)
expect(Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get())).toBeDefined()
expect(Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())?.project_id).toBe(remoteID)
}),
)
})
describe("Project.fromDirectory git failure paths", () => {
@ -200,7 +295,7 @@ describe("Project.fromDirectory with worktrees", () => {
}),
)
it.live("should set worktree to root when called from a worktree", () =>
it.live("tracks a linked worktree as the opened project directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
@ -217,9 +312,9 @@ describe("Project.fromDirectory with worktrees", () => {
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.worktree).toBe(tmp)
expect(project.worktree).toBe(worktreePath)
expect(sandbox).toBe(worktreePath)
expect(project.sandboxes).toContain(worktreePath)
expect(project.sandboxes).not.toContain(worktreePath)
expect(project.sandboxes).not.toContain(tmp)
}),
)
@ -245,7 +340,6 @@ describe("Project.fromDirectory with worktrees", () => {
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, ".git", "opencode")
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
expect(exists).toBe(true)
@ -300,8 +394,7 @@ describe("Project.fromDirectory with worktrees", () => {
yield* run((svc) => svc.fromDirectory(worktree1))
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(tmp)
expect(project.sandboxes).toContain(worktree1)
expect(project.worktree).toBe(worktree1)
expect(project.sandboxes).toContain(worktree2)
expect(project.sandboxes).not.toContain(tmp)
}),
@ -640,7 +733,7 @@ describe("Project.fromDirectory with bare repos", () => {
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(barePath)
expect(project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
@ -703,7 +796,7 @@ describe("Project.fromDirectory with bare repos", () => {
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(barePath)
expect(project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)