From a9ef5a0fae7d390ed59ac7da087911deddd68bb9 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 23 May 2026 00:48:09 -0400 Subject: [PATCH] feat(project): resolve remote-backed project identity (#28914) --- packages/core/src/git.ts | 111 ++++++++++ packages/core/src/project.ts | 129 ++++++++++++ packages/core/src/schema.ts | 6 + packages/core/test/project.test.ts | 185 +++++++++++++++++ packages/opencode/src/project/project.ts | 193 +++++++----------- .../opencode/test/project/project.test.ts | 115 ++++++++++- 6 files changed, 613 insertions(+), 126 deletions(-) create mode 100644 packages/core/src/git.ts create mode 100644 packages/core/src/project.ts create mode 100644 packages/core/test/project.test.ts diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts new file mode 100644 index 0000000000..500a69ea4d --- /dev/null +++ b/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 + readonly remote: (repo: Repo, name?: string) => Effect.Effect + readonly roots: (repo: Repo) => Effect.Effect +} + +export class Service extends Context.Service()("@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) +} diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts new file mode 100644 index 0000000000..9c265d75be --- /dev/null +++ b/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("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 +} + +export class Service extends Context.Service()("@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)) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 5b4042c736..523a4eace5 100644 --- a/packages/core/src/schema.ts +++ b/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. */ diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts new file mode 100644 index 0000000000..bc457d767c --- /dev/null +++ b/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") + }), + ) +}) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 8e668bca41..ce7997765a 100644 --- a/packages/opencode/src/project/project.ts +++ b/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(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()("@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), ) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 56f4ae3f61..ff5478a4d4 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/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(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 @@ -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[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)