project.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. export * as ProjectV2 from "./project"
  2. export * as Project from "./project"
  3. import { Context, Effect, Layer, Schema } from "effect"
  4. import { asc, desc, eq } from "drizzle-orm"
  5. import path from "path"
  6. import { AbsolutePath, withStatics } from "./schema"
  7. import { FSUtil } from "./fs-util"
  8. import { Database } from "./database/database"
  9. import { Git } from "./git"
  10. import { LayerNode } from "./effect/layer-node"
  11. import { Hash } from "./util/hash"
  12. import { ProjectDirectoryTable } from "./project/sql"
  13. export const ID = Schema.String.pipe(
  14. Schema.brand("Project.ID"),
  15. withStatics((schema) => ({
  16. global: schema.make("global"),
  17. })),
  18. )
  19. export type ID = typeof ID.Type
  20. export const Vcs = Schema.Union([
  21. Schema.Struct({
  22. type: Schema.Literal("git"),
  23. store: AbsolutePath,
  24. }),
  25. ])
  26. export type Vcs = typeof Vcs.Type
  27. export class Info extends Schema.Class<Info>("Project.Info")({
  28. id: ID,
  29. }) {}
  30. export const DirectoriesInput = Schema.Struct({
  31. projectID: ID,
  32. }).annotate({ identifier: "Project.DirectoriesInput" })
  33. export type DirectoriesInput = typeof DirectoriesInput.Type
  34. export const Directories = Schema.Array(
  35. Schema.Struct({
  36. directory: AbsolutePath,
  37. type: Schema.Literals(["main", "root", "git_worktree"]),
  38. }),
  39. ).annotate({ identifier: "Project.Directories" })
  40. export type Directories = typeof Directories.Type
  41. export interface Interface {
  42. readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
  43. readonly resolve: (input: AbsolutePath) => Effect.Effect<
  44. {
  45. previous?: ID
  46. id: ID
  47. directory: AbsolutePath
  48. vcs?: Vcs
  49. },
  50. never
  51. >
  52. /**
  53. * Temporary bridge method for writing the resolved project ID to the repo-local cache.
  54. *
  55. * This exists while the old opencode project service and this core project
  56. * service work together: core resolves the ID, while the old service still owns
  57. * database migration and persistence. The old service should call this after it
  58. * finishes migrating from `resolve().previous` to `resolve().id`; once project
  59. * persistence moves into core, this separate bridge method can go away.
  60. */
  61. readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
  62. }
  63. export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
  64. export const layer = Layer.effect(
  65. Service,
  66. Effect.gen(function* () {
  67. const db = (yield* Database.Service).db
  68. const fs = yield* FSUtil.Service
  69. const git = yield* Git.Service
  70. const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
  71. const rows = yield* db
  72. .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
  73. .from(ProjectDirectoryTable)
  74. .where(eq(ProjectDirectoryTable.project_id, input.projectID))
  75. .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
  76. .all()
  77. .pipe(Effect.orDie)
  78. return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type }))
  79. })
  80. const cached = Effect.fnUntraced(function* (dir: string) {
  81. return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
  82. Effect.map((value) => value.trim()),
  83. Effect.map((value) => (value ? ID.make(value) : undefined)),
  84. Effect.catch(() => Effect.succeed(undefined)),
  85. )
  86. })
  87. const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
  88. const origin = yield* git.remote(repo)
  89. if (!origin) return undefined
  90. const normalized = url(origin)
  91. if (!normalized) return undefined
  92. return ID.make(Hash.fast(`git-remote:${normalized}`))
  93. })
  94. function url(input: string) {
  95. const value = input.trim()
  96. if (!value) return undefined
  97. try {
  98. const parsed = new URL(value)
  99. if (parsed.protocol === "file:") return undefined
  100. return parts(parsed.hostname, parsed.pathname)
  101. } catch {
  102. const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
  103. if (scp) return parts(scp[2], scp[3])
  104. return undefined
  105. }
  106. }
  107. function parts(host: string, name: string) {
  108. const pathname = name
  109. .replace(/^\/+/, "")
  110. .replace(/\.git\/?$/, "")
  111. .replace(/\/+$/, "")
  112. if (!host || !pathname) return undefined
  113. return `${host.toLowerCase()}/${pathname}`
  114. }
  115. const root = Effect.fnUntraced(function* (repo: Git.Repo) {
  116. const root = (yield* git.roots(repo))[0]
  117. return root ? ID.make(root) : undefined
  118. })
  119. const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
  120. const repo = yield* git.find(input)
  121. if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
  122. const previous = yield* cached(repo.store)
  123. const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
  124. return {
  125. previous,
  126. id: id ?? ID.global,
  127. directory: repo.directory,
  128. vcs: { type: "git" as const, store: repo.store },
  129. }
  130. })
  131. const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
  132. yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
  133. })
  134. return Service.of({ directories, resolve, commit })
  135. }),
  136. )
  137. export const defaultLayer = layer.pipe(
  138. Layer.provide(Database.defaultLayer),
  139. Layer.provide(FSUtil.defaultLayer),
  140. Layer.provide(Git.defaultLayer),
  141. )
  142. export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])