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)
}),
)
})