Просмотр исходного кода

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

Dax 3 месяцев назад
Родитель
Сommit
a9ef5a0fae

+ 111 - 0
packages/core/src/git.ts

@@ -0,0 +1,111 @@
+export * as Git from "./git"
+
+import path from "path"
+import { Context, Effect, Layer } from "effect"
+import { ChildProcess } from "effect/unstable/process"
+import { AbsolutePath } from "./schema"
+import { AppFileSystem } from "./filesystem"
+import { AppProcess } from "./process"
+
+export interface Repo {
+  /**
+   * The root directory of the working tree that contains the input path.
+   *
+   * For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
+   * For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
+   * `/home/me/app-feature`.
+   */
+  readonly directory: AbsolutePath
+  /**
+   * The shared Git storage directory used by this repo and any linked worktrees.
+   *
+   * For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
+   * For a linked worktree at `/home/me/app-feature` whose main checkout is
+   * `/home/me/app`, this is usually `/home/me/app/.git`.
+   */
+  readonly store: AbsolutePath
+}
+
+export interface Interface {
+  readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
+  readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
+  readonly roots: (repo: Repo) => Effect.Effect<string[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+    const proc = yield* AppProcess.Service
+
+    const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
+      const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
+        Effect.map((matches) => matches[0]),
+        Effect.catch(() => Effect.succeed(undefined)),
+      )
+      if (!dotgit) return undefined
+
+      const cwd = path.dirname(dotgit)
+      const git = run(cwd, proc)
+      const topLevel = yield* git(["rev-parse", "--show-toplevel"])
+      const commonDir = yield* git(["rev-parse", "--git-common-dir"])
+      if (commonDir.exitCode !== 0) return undefined
+
+      return {
+        directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
+        store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
+      } satisfies Repo
+    })
+
+    const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
+      const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
+      if (result.exitCode !== 0) return undefined
+      return result.text.trim() || undefined
+    })
+
+    const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
+      const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
+      if (result.exitCode !== 0) return []
+      return result.text
+        .split("\n")
+        .map((item) => item.trim())
+        .filter(Boolean)
+        .toSorted()
+    })
+
+    return Service.of({ find, remote, roots })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppProcess.defaultLayer))
+
+interface Result {
+  readonly exitCode: number
+  readonly text: string
+}
+
+function run(cwd: string, proc: AppProcess.Interface) {
+  return (args: string[]) =>
+    proc
+      .run(
+        ChildProcess.make("git", args, {
+          cwd,
+          extendEnv: true,
+          stdin: "ignore",
+        }),
+      )
+      .pipe(
+        Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") } satisfies Result)),
+        Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
+      )
+}
+
+function resolvePath(cwd: string, value: string) {
+  const trimmed = value.replace(/[\r\n]+$/, "")
+  if (!trimmed) return cwd
+  const normalized = AppFileSystem.windowsPath(trimmed)
+  if (path.isAbsolute(normalized)) return path.normalize(normalized)
+  return path.resolve(cwd, normalized)
+}

+ 129 - 0
packages/core/src/project.ts

@@ -0,0 +1,129 @@
+export * as Project from "./project"
+
+import { Context, Effect, Layer, Schema } from "effect"
+import path from "path"
+import { AbsolutePath, withStatics } from "./schema"
+import { AppFileSystem } from "./filesystem"
+import { Git } from "./git"
+import { Hash } from "./util/hash"
+
+export const ID = Schema.String.pipe(
+  Schema.brand("Project.ID"),
+  withStatics((schema) => ({
+    global: schema.make("global"),
+  })),
+)
+export type ID = typeof ID.Type
+
+export const Vcs = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("git"),
+    store: AbsolutePath,
+  }),
+])
+export type Vcs = typeof Vcs.Type
+
+export class Info extends Schema.Class<Info>("Project.Info")({
+  id: ID,
+  vcs: Schema.optional(Vcs),
+}) {}
+
+export interface Interface {
+  readonly resolve: (input: AbsolutePath) => Effect.Effect<
+    {
+      previous?: ID
+      id: ID
+      directory: AbsolutePath
+      vcs?: Vcs
+    },
+    never
+  >
+  /**
+   * Temporary bridge method for writing the resolved project ID to the repo-local cache.
+   *
+   * This exists while the old opencode project service and this core project
+   * service work together: core resolves the ID, while the old service still owns
+   * database migration and persistence. The old service should call this after it
+   * finishes migrating from `resolve().previous` to `resolve().id`; once project
+   * persistence moves into core, this separate bridge method can go away.
+   */
+  readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+    const git = yield* Git.Service
+
+    const cached = Effect.fnUntraced(function* (dir: string) {
+      return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
+        Effect.map((value) => value.trim()),
+        Effect.map((value) => (value ? ID.make(value) : undefined)),
+        Effect.catch(() => Effect.succeed(undefined)),
+      )
+    })
+
+    const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
+      const origin = yield* git.remote(repo)
+      if (!origin) return undefined
+      const normalized = url(origin)
+      if (!normalized) return undefined
+      return ID.make(Hash.fast(`git-remote:${normalized}`))
+    })
+
+    function url(input: string) {
+      const value = input.trim()
+      if (!value) return undefined
+
+      try {
+        const parsed = new URL(value)
+        if (parsed.protocol === "file:") return undefined
+        return parts(parsed.hostname, parsed.pathname)
+      } catch {
+        const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
+        if (scp) return parts(scp[2], scp[3])
+        return undefined
+      }
+    }
+
+    function parts(host: string, name: string) {
+      const pathname = name
+        .replace(/^\/+/, "")
+        .replace(/\.git\/?$/, "")
+        .replace(/\/+$/, "")
+      if (!host || !pathname) return undefined
+      return `${host.toLowerCase()}/${pathname}`
+    }
+
+    const root = Effect.fnUntraced(function* (repo: Git.Repo) {
+      const root = (yield* git.roots(repo))[0]
+      return root ? ID.make(root) : undefined
+    })
+
+    const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
+      const repo = yield* git.find(input)
+      if (!repo) return { id: ID.global, directory: input, vcs: undefined }
+
+      const previous = yield* cached(repo.store)
+      const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
+
+      return {
+        previous,
+        id: id ?? ID.global,
+        directory: repo.directory,
+        vcs: { type: "git" as const, store: repo.store },
+      }
+    })
+
+    const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
+      yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
+    })
+
+    return Service.of({ resolve, commit })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))

+ 6 - 0
packages/core/src/schema.ts

@@ -1,5 +1,11 @@
 import { Option, Schema, SchemaGetter } from "effect"
 
+export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
+export type AbsolutePath = typeof AbsolutePath.Type
+
+export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
+export type RelativePath = typeof RelativePath.Type
+
 /**
  * Integer greater than zero.
  */

+ 185 - 0
packages/core/test/project.test.ts

@@ -0,0 +1,185 @@
+import { describe, expect } from "bun:test"
+import { $ } from "bun"
+import fs from "fs/promises"
+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)
+}
+
+function real(value: string) {
+  return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
+}
+
+async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
+  await $`git init`.cwd(dir).quiet()
+  await $`git config core.fsmonitor false`.cwd(dir).quiet()
+  await $`git config commit.gpgsign false`.cwd(dir).quiet()
+  await $`git config user.email test@opencode.test`.cwd(dir).quiet()
+  await $`git config user.name Test`.cwd(dir).quiet()
+  if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
+  if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
+}
+
+async function rootCommit(dir: string) {
+  return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
+}
+
+describe("ProjectV2.resolve", () => {
+  it.live("returns global for non-git directory", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(tmp.path))
+
+      expect(result.id).toBe(Project.ID.make("global"))
+      expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
+      expect(result.previous).toBeUndefined()
+      expect(result.vcs).toBeUndefined()
+    }),
+  )
+
+  it.live("returns git global for repo with no commits and no remote", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      yield* Effect.promise(() => initRepo(tmp.path))
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(tmp.path))
+
+      expect(result.id).toBe(Project.ID.make("global"))
+      expect(result.directory).toBe(yield* real(tmp.path))
+      expect(result.previous).toBeUndefined()
+      expect(result.vcs?.type).toBe("git")
+    }),
+  )
+
+  it.live("falls back to root commit when origin is missing", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(tmp.path))
+
+      expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
+      expect(result.directory).toBe(yield* real(tmp.path))
+      expect(result.previous).toBeUndefined()
+      expect(result.vcs?.type).toBe("git")
+    }),
+  )
+
+  it.live("prefers normalized origin over root commit", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(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("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()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(tmp.path))
+
+      expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
+    }),
+  )
+
+  it.live("returns previous cached id from common dir", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
+      yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(tmp.path))
+
+      expect(result.previous).toBe(Project.ID.make("old-id"))
+      expect(result.id).toBe(remoteID("github.com/owner/repo"))
+    }),
+  )
+
+  it.live("does not write the cache while resolving", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
+      const project = yield* Project.Service
+
+      yield* project.resolve(abs(tmp.path))
+
+      expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
+    }),
+  )
+
+  it.live("resolves from nested directories to repo root", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
+      yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
+
+      expect(result.directory).toBe(yield* real(tmp.path))
+    }),
+  )
+
+  it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
+    Effect.gen(function* () {
+      const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()))
+      const worktree = `${tmp.path}-worktree`
+      yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore))
+      yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
+      yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
+      yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
+      const project = yield* Project.Service
+
+      const result = yield* project.resolve(abs(worktree))
+
+      expect(result.directory).toBe(yield* real(worktree))
+      expect(result.previous).toBe(Project.ID.make("old-id"))
+      expect(result.id).toBe(remoteID("github.com/owner/repo"))
+      expect(result.vcs?.type).toBe("git")
+    }),
+  )
+})

+ 78 - 115
packages/opencode/src/project/project.ts

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

+ 104 - 11
packages/opencode/test/project/project.test.ts

@@ -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)