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
6 changed files with 613 additions and 126 deletions

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)