From 0f0a4be2538038bcde534f0b5454fbf834cc2574 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 03:09:20 +0000 Subject: [PATCH] fix(project): restore remote project identity (#29495) --- packages/core/src/project.ts | 2 +- packages/core/test/project.test.ts | 40 +++++++-- .../opencode/test/project/project.test.ts | 86 +++++++++++++++++-- 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index e25dd16122..9c265d75be 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -108,7 +108,7 @@ export const layer = Layer.effect( if (!repo) return { id: ID.global, directory: input, vcs: undefined } const previous = yield* cached(repo.store) - const id = previous ?? (yield* root(repo)) + const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) return { previous, diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index a6d192bb1d..c5b96b6389 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -5,11 +5,16 @@ import path from "path" import { Effect } from "effect" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Hash } from "@opencode-ai/core/util/hash" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const it = testEffect(Project.defaultLayer) +function remoteID(remote: string) { + return Project.ID.make(Hash.fast(`git-remote:${remote}`)) +} + function abs(value: string) { return AbsolutePath.make(value) } @@ -86,7 +91,7 @@ describe("ProjectV2.resolve", () => { }), ) - it.live("uses root commit when origin exists", () => + it.live("prefers normalized origin over root commit", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -97,13 +102,36 @@ describe("ProjectV2.resolve", () => { const result = yield* project.resolve(abs(tmp.path)) - expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) + expect(result.id).toBe(remoteID("github.com/Acme/App")) + expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) expect(result.directory).toBe(yield* real(tmp.path)) expect(result.vcs?.type).toBe("git") }), ) - it.live("uses root commit when local remote exists", () => + it.live("normalizes ssh and https remotes to the same id", () => + Effect.gen(function* () { + const ssh = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const https = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" })) + yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" })) + const project = yield* Project.Service + + const a = yield* project.resolve(abs(ssh.path)) + const b = yield* project.resolve(abs(https.path)) + + expect(a.id).toBe(remoteID("github.com/owner/repo")) + expect(b.id).toBe(a.id) + }), + ) + + it.live("ignores file remotes and falls back to root commit", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -118,7 +146,7 @@ describe("ProjectV2.resolve", () => { }), ) - it.live("prefers previous cached id over origin", () => + it.live("returns previous cached id from common dir", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -131,7 +159,7 @@ describe("ProjectV2.resolve", () => { const result = yield* project.resolve(abs(tmp.path)) expect(result.previous).toBe(Project.ID.make("old-id")) - expect(result.id).toBe(Project.ID.make("old-id")) + expect(result.id).toBe(remoteID("github.com/owner/repo")) }), ) @@ -185,7 +213,7 @@ describe("ProjectV2.resolve", () => { expect(result.directory).toBe(yield* real(worktree)) expect(result.previous).toBe(Project.ID.make("old-id")) - expect(result.id).toBe(Project.ID.make("old-id")) + expect(result.id).toBe(remoteID("github.com/owner/repo")) expect(result.vcs?.type).toBe("git") }), ) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e636fef90e..869326d87a 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -7,6 +7,15 @@ 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" @@ -31,6 +40,10 @@ function run(fn: (svc: Project.Interface) => Effect.Effect) { }) } +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 @@ -154,28 +167,91 @@ describe("Project.fromDirectory", () => { }), ) - it.live("keeps root commit identity when origin exists", () => + 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)) - const root = (yield* Effect.promise(() => $`git rev-list --max-parents=0 HEAD`.cwd(tmp).text())).trim() - expect(project.id).toBe(ProjectID.make(root)) + expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo")) }), ) - it.live("keeps cached project identity when origin becomes available", () => + 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(rootProject.id) + 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) }), ) })