project.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. export * as ProjectV2 from "./project"
  2. export * as Project from "./project"
  3. import { Context, Effect, Layer, Schema } from "effect"
  4. import path from "path"
  5. import { AbsolutePath } from "./schema"
  6. import { FSUtil } from "./fs-util"
  7. import { Git } from "./git"
  8. import { makeGlobalNode } from "./effect/node"
  9. import { Hash } from "./util/hash"
  10. import { ProjectDirectories } from "./project/directories"
  11. import { ProjectSchema } from "./project/schema"
  12. export const ID = ProjectSchema.ID
  13. export type ID = ProjectSchema.ID
  14. export const Vcs = ProjectSchema.Vcs
  15. export type Vcs = ProjectSchema.Vcs
  16. export class Info extends Schema.Class<Info>("Project.Info")({
  17. id: ID,
  18. }) {}
  19. export const DirectoriesInput = ProjectDirectories.ListInput
  20. export type DirectoriesInput = typeof DirectoriesInput.Type
  21. export const Directories = ProjectDirectories.ListOutput
  22. export type Directories = typeof Directories.Type
  23. export interface Resolved {
  24. readonly previous?: ID
  25. readonly id: ID
  26. readonly directory: AbsolutePath
  27. readonly vcs?: Vcs
  28. }
  29. export interface Interface {
  30. readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
  31. readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
  32. /**
  33. * Temporary bridge method for writing the resolved project ID to the repo-local cache.
  34. *
  35. * This exists while the old opencode project service and this core project
  36. * service work together: core resolves the ID, while the old service still owns
  37. * database migration and persistence. The old service should call this after it
  38. * finishes migrating from `resolve().previous` to `resolve().id`; once project
  39. * persistence moves into core, this separate bridge method can go away.
  40. */
  41. readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
  42. }
  43. export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
  44. export const layer = Layer.effect(
  45. Service,
  46. Effect.gen(function* () {
  47. const fs = yield* FSUtil.Service
  48. const git = yield* Git.Service
  49. const projectDirectories = yield* ProjectDirectories.Service
  50. const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
  51. return yield* projectDirectories.list(input.projectID)
  52. })
  53. const cached = Effect.fnUntraced(function* (dir: string) {
  54. return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
  55. Effect.map((value) => value.trim()),
  56. Effect.map((value) => (value ? ID.make(value) : undefined)),
  57. Effect.catch(() => Effect.succeed(undefined)),
  58. )
  59. })
  60. const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
  61. const origin = yield* git.remote.get(repo)
  62. if (!origin) return undefined
  63. const normalized = url(origin)
  64. if (!normalized) return undefined
  65. return ID.make(Hash.fast(`git-remote:${normalized}`))
  66. })
  67. function url(input: string) {
  68. const value = input.trim()
  69. if (!value) return undefined
  70. try {
  71. const parsed = new URL(value)
  72. if (parsed.protocol === "file:") return undefined
  73. return parts(parsed.hostname, parsed.pathname)
  74. } catch {
  75. const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
  76. if (scp) return parts(scp[2], scp[3])
  77. return undefined
  78. }
  79. }
  80. function parts(host: string, name: string) {
  81. const pathname = name
  82. .replace(/^\/+/, "")
  83. .replace(/\.git\/?$/, "")
  84. .replace(/\/+$/, "")
  85. if (!host || !pathname) return undefined
  86. return `${host.toLowerCase()}/${pathname}`
  87. }
  88. const root = Effect.fnUntraced(function* (repo: Git.Repository) {
  89. const root = (yield* git.history.rootCommits(repo))[0]
  90. return root ? ID.make(root) : undefined
  91. })
  92. const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
  93. const repo = yield* git.repo.discover(input)
  94. if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
  95. const previous = yield* cached(repo.commonDirectory)
  96. const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
  97. return {
  98. previous,
  99. id: id ?? ID.global,
  100. directory: repo.worktree,
  101. vcs: { type: "git" as const, store: repo.commonDirectory },
  102. }
  103. })
  104. const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
  105. yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
  106. })
  107. return Service.of({ directories, resolve, commit })
  108. }),
  109. )
  110. export const defaultLayer = layer.pipe(
  111. Layer.provide(FSUtil.defaultLayer),
  112. Layer.provide(Git.defaultLayer),
  113. Layer.provideMerge(ProjectDirectories.defaultLayer),
  114. )
  115. export const node = makeGlobalNode({
  116. service: Service,
  117. layer: layer,
  118. deps: [FSUtil.node, Git.node, ProjectDirectories.node],
  119. })