Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 51 additions & 10 deletions packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ export type ID = ProjectSchema.ID
export const Vcs = ProjectSchema.Vcs
export type Vcs = ProjectSchema.Vcs

const STABLE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

/**
* Whether an id is a stable minted repo identity (uuid) rather than a legacy
* derived id (remote hash, root commit, cached value) or the global sentinel.
*/
export function isStableID(id: string) {
return STABLE_ID_PATTERN.test(id)
}

export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
}) {}
Expand All @@ -38,15 +48,17 @@ export interface Interface {
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
* Temporary bridge method for writing a project's minted identity to the
* repo-local cache (`<commonDir>/opencode`) as versioned JSON.
*
* 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.
* minting, database migration, and persistence. Returns whether the write
* landed so callers only adopt a minted identity that is durably stored;
* once project persistence moves into core, this separate bridge method can
* go away.
*/
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<boolean>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
Expand All @@ -62,12 +74,28 @@ const layer = Layer.effect(
return yield* projectDirectories.list(input.projectID)
})

const parse = (content: string): { repoID?: ID; legacy?: ID } => {
try {
const parsed: unknown = JSON.parse(content)
if (parsed && typeof parsed === "object") {
const repoID = "repoID" in parsed ? parsed.repoID : undefined
// Forward-compatible read: honor the repoID of any structured
// version, ignore structured content we do not understand.
if (typeof repoID === "string" && isStableID(repoID)) return { repoID: ID.make(repoID) }
return {}
}
} catch {}
// Bare string contents predate the versioned format.
return { legacy: ID.make(content) }
}

const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
const content = 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)),
)
if (!content) return { repoID: undefined, legacy: undefined }
return parse(content)
})

const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
Expand Down Expand Up @@ -111,18 +139,31 @@ const layer = Layer.effect(
const repo = yield* git.repo.discover(input)
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }

const previous = yield* cached(repo.commonDirectory)
const vcs = { type: "git" as const, store: repo.commonDirectory }
const stored = yield* cached(repo.commonDirectory)
// A minted identity persisted in the versioned cache file is
// authoritative: it is what keeps independent clones of the same
// remote distinct while linked worktrees (shared common dir) and
// renamed checkouts keep resolving to the same project.
if (stored.repoID) return { id: stored.repoID, directory: repo.worktree, vcs }

const previous = stored.legacy
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
vcs,
}
})

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 yield* fs
.writeFileString(path.join(input.store, "opencode"), JSON.stringify({ version: 1, repoID: input.id }) + "\n")
.pipe(
Effect.map(() => true),
Effect.catch(() => Effect.succeed(false)),
)
})

return Service.of({ directories, resolve, commit })
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/effect/layer-node/node-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe("node build", () => {
return Project.Service.of({
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
commit: () => Effect.void,
commit: () => Effect.succeed(true),
})
}),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const projectLayer = Layer.succeed(
directory: AbsolutePath.make("/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
commit: () => Effect.succeed(true),
}),
)
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
Expand Down
162 changes: 162 additions & 0 deletions packages/core/test/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,165 @@ describe("ProjectV2.resolve", () => {
}),
)
})

describe("ProjectV2 versioned identity file", () => {
const uuid = "b3f1c2a0-4d5e-4f6a-8b7c-0123456789ab"

it.live("honors v1 repoID from .git/opencode over the remote", () =>
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"), JSON.stringify({ version: 1, repoID: uuid })),
)
const project = yield* ProjectV2.Service

const result = yield* project.resolve(abs(tmp.path))

expect(result.id).toBe(ProjectV2.ID.make(uuid))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
)

it.live("two clones of the same remote resolve to their own v1 identities", () =>
Effect.gen(function* () {
const a = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const b = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const uuidB = "0f9e8d7c-6b5a-4f3e-9d1c-ba9876543210"
yield* Effect.promise(() => initRepo(a.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(b.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() =>
Bun.write(path.join(a.path, ".git", "opencode"), JSON.stringify({ version: 1, repoID: uuid })),
)
yield* Effect.promise(() =>
Bun.write(path.join(b.path, ".git", "opencode"), JSON.stringify({ version: 1, repoID: uuidB })),
)
const project = yield* ProjectV2.Service

const first = yield* project.resolve(abs(a.path))
const second = yield* project.resolve(abs(b.path))

expect(first.id).toBe(ProjectV2.ID.make(uuid))
expect(second.id).toBe(ProjectV2.ID.make(uuidB))
expect(first.id).not.toBe(second.id)
}),
)

it.live("linked worktree resolves to the clone's v1 repoID", () =>
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"), JSON.stringify({ version: 1, repoID: uuid })),
)
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
const project = yield* ProjectV2.Service

const result = yield* project.resolve(abs(worktree))

expect(result.id).toBe(ProjectV2.ID.make(uuid))
expect(result.previous).toBeUndefined()
expect(result.directory).toBe(yield* real(worktree))
}),
)

it.live("identity survives a folder rename", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const renamed = `${tmp.path}-renamed`
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${renamed}`.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"), JSON.stringify({ version: 1, repoID: uuid })),
)
yield* Effect.promise(() => fs.rename(tmp.path, renamed))
const project = yield* ProjectV2.Service

const result = yield* project.resolve(abs(renamed))

expect(result.id).toBe(ProjectV2.ID.make(uuid))
expect(result.previous).toBeUndefined()
expect(result.directory).toBe(yield* real(renamed))
}),
)

it.live("ignores structured content without a valid repoID and falls back to legacy chain", () =>
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"), JSON.stringify({ version: 99, other: "thing" })),
)
const project = yield* ProjectV2.Service

const result = yield* project.resolve(abs(tmp.path))

expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.previous).toBeUndefined()
}),
)

it.live("treats a non-uuid bare string as the legacy previous id", () =>
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"), Hash.fast("git-remote:github.com/owner/repo")),
)
const project = yield* ProjectV2.Service

const result = yield* project.resolve(abs(tmp.path))

expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.previous).toBe(remoteID("github.com/owner/repo"))
}),
)

it.live("commit writes the versioned identity file and round-trips through resolve", () =>
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* ProjectV2.Service

yield* project.commit({ store: abs(path.join(tmp.path, ".git")), id: ProjectV2.ID.make(uuid) })

const content = yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).text())
expect(JSON.parse(content)).toEqual({ version: 1, repoID: uuid })

const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(ProjectV2.ID.make(uuid))
expect(result.previous).toBeUndefined()
}),
)
})
2 changes: 1 addition & 1 deletion packages/core/test/session-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const projects = Layer.succeed(
ProjectV2.Service.of({
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
commit: () => Effect.succeed(true),
}),
)
const it = testEffect(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/session-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const projects = Layer.succeed(
ProjectV2.Service.of({
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
commit: () => Effect.succeed(true),
}),
)
const it = testEffect(
Expand Down
Loading
Loading