| 1 | import * as Cache from "effect/Cache"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | |
| 10 | import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; |
| 11 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; |
| 12 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 13 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; |
| 14 | import * as VcsDriver from "./VcsDriver.ts"; |
| 15 | |
| 16 | const DETECTION_CACHE_CAPACITY = 2_048; |
| 17 | // Status polls detect every open cwd each tick, and detection costs three git |
| 18 | // processes. `detect` re-checks each hit against the disk, so this TTL only |
| 19 | // bounds rare changes those stats cannot see. |
| 20 | const DETECTION_CACHE_TTL = Duration.minutes(5); |
| 21 | |
| 22 | export interface VcsDriverResolveInput { |
| 23 | readonly cwd: string; |
| 24 | readonly requestedKind?: VcsDriverKind | "auto"; |
| 25 | } |
| 26 | |
| 27 | export interface VcsDriverHandle { |
| 28 | readonly kind: VcsDriverKind; |
| 29 | readonly repository: VcsRepositoryIdentity; |
| 30 | readonly driver: VcsDriver.VcsDriver["Service"]; |
| 31 | } |
| 32 | |
| 33 | export class VcsDriverRegistry extends Context.Service< |
| 34 | VcsDriverRegistry, |
| 35 | { |
| 36 | readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriver.VcsDriver["Service"], VcsError>; |
| 37 | readonly detect: ( |
| 38 | input: VcsDriverResolveInput, |
| 39 | ) => Effect.Effect<VcsDriverHandle | null, VcsError>; |
| 40 | readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect<VcsDriverHandle, VcsError>; |
| 41 | } |
| 42 | >()("t3/vcs/VcsDriverRegistry") {} |
| 43 | |
| 44 | function detectionCacheKey(input: { |
| 45 | readonly cwd: string; |
| 46 | readonly requestedKind: VcsDriverKind | "auto"; |
| 47 | }): string { |
| 48 | return `${input.requestedKind}\0${input.cwd}`; |
| 49 | } |
| 50 | |
| 51 | function parseDetectionCacheKey(key: string): { |
| 52 | readonly cwd: string; |
| 53 | readonly requestedKind: VcsDriverKind | "auto"; |
| 54 | } { |
| 55 | const separatorIndex = key.indexOf("\0"); |
| 56 | if (separatorIndex === -1) { |
| 57 | return { |
| 58 | cwd: key, |
| 59 | requestedKind: "auto", |
| 60 | }; |
| 61 | } |
| 62 | return { |
| 63 | requestedKind: key.slice(0, separatorIndex) as VcsDriverKind | "auto", |
| 64 | cwd: key.slice(separatorIndex + 1), |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | export const make = Effect.gen(function* () { |
| 69 | const fileSystem = yield* FileSystem.FileSystem; |
| 70 | const path = yield* Path.Path; |
| 71 | const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; |
| 72 | const git = yield* GitVcsDriver.makeVcsDriver; |
| 73 | const drivers: Partial<Record<VcsDriverKind, VcsDriver.VcsDriver["Service"]>> = { |
| 74 | git, |
| 75 | }; |
| 76 | |
| 77 | const get: VcsDriverRegistry["Service"]["get"] = (kind) => { |
| 78 | const driver = drivers[kind]; |
| 79 | if (!driver) { |
| 80 | return Effect.fail( |
| 81 | new VcsUnsupportedOperationError({ |
| 82 | operation: "VcsDriverRegistry.get", |
| 83 | kind, |
| 84 | detail: `No ${kind} VCS driver is registered.`, |
| 85 | }), |
| 86 | ); |
| 87 | } |
| 88 | return Effect.succeed(driver); |
| 89 | }; |
| 90 | |
| 91 | const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( |
| 92 | kind: VcsDriverKind, |
| 93 | driver: VcsDriver.VcsDriver["Service"], |
| 94 | cwd: string, |
| 95 | ) { |
| 96 | const repository = yield* driver.detectRepository(cwd); |
| 97 | if (!repository) { |
| 98 | return null; |
| 99 | } |
| 100 | return { |
| 101 | kind, |
| 102 | repository, |
| 103 | driver, |
| 104 | } satisfies VcsDriverHandle; |
| 105 | }); |
| 106 | |
| 107 | const detectResolvedKind = Effect.fn("VcsDriverRegistry.detectResolvedKind")(function* (input: { |
| 108 | readonly cwd: string; |
| 109 | readonly requestedKind: VcsDriverKind | "auto"; |
| 110 | }) { |
| 111 | const requestedKind = input.requestedKind; |
| 112 | |
| 113 | if (requestedKind !== "auto" && requestedKind !== "unknown") { |
| 114 | const driver = yield* get(requestedKind); |
| 115 | return yield* detectWithDriver(requestedKind, driver, input.cwd); |
| 116 | } |
| 117 | |
| 118 | return yield* detectWithDriver("git", git, input.cwd); |
| 119 | }); |
| 120 | |
| 121 | // The cwd and each parent below the repository root. A new `.git` in any of |
| 122 | // them means `git init` made a nested repository. Falls back to the cwd alone |
| 123 | // when the root is not above it, such as a cwd reached through a symlink. |
| 124 | const foldersBelowRoot = (cwd: string, rootPath: string) => { |
| 125 | const relative = path.relative(path.resolve(rootPath), path.resolve(cwd)); |
| 126 | if (relative.startsWith("..") || path.isAbsolute(relative)) return [cwd]; |
| 127 | return Array.from({ length: relative.split(path.sep).length }, (_, levelsUp) => |
| 128 | path.resolve(cwd, ...Array<string>(levelsUp).fill("..")), |
| 129 | ); |
| 130 | }; |
| 131 | |
| 132 | // What `detect` re-checks on every cache hit, using stats instead of git. |
| 133 | const readDiskState = (cwd: string, repository: VcsRepositoryIdentity) => |
| 134 | Effect.all({ |
| 135 | gitEntries: Effect.forEach(foldersBelowRoot(cwd, repository.rootPath), (folder) => |
| 136 | fileSystem.exists(path.join(folder, ".git")), |
| 137 | ), |
| 138 | repositoryOnDisk: Effect.forEach( |
| 139 | repository.metadataPath === null |
| 140 | ? [cwd] |
| 141 | : [cwd, path.resolve(cwd, repository.metadataPath)], |
| 142 | (target) => fileSystem.exists(target), |
| 143 | ).pipe(Effect.map((results) => results.every(Boolean))), |
| 144 | }).pipe(Effect.orElseSucceed(() => null)); |
| 145 | |
| 146 | const detectionCache = yield* Cache.makeWith< |
| 147 | string, |
| 148 | { readonly handle: VcsDriverHandle; readonly gitEntries: ReadonlyArray<boolean> } | null, |
| 149 | VcsError |
| 150 | >( |
| 151 | (key) => |
| 152 | Effect.gen(function* () { |
| 153 | const input = parseDetectionCacheKey(key); |
| 154 | const handle = yield* detectResolvedKind(input); |
| 155 | if (handle === null) return null; |
| 156 | const disk = yield* readDiskState(input.cwd, handle.repository); |
| 157 | return { handle, gitEntries: disk?.gitEntries ?? [] }; |
| 158 | }), |
| 159 | { |
| 160 | capacity: DETECTION_CACHE_CAPACITY, |
| 161 | timeToLive: Exit.match({ |
| 162 | onSuccess: (detected) => (detected === null ? Duration.zero : DETECTION_CACHE_TTL), |
| 163 | onFailure: () => Duration.zero, |
| 164 | }), |
| 165 | }, |
| 166 | ); |
| 167 | |
| 168 | // A hit is stale when its worktree or `.git` folder was removed, or when |
| 169 | // `git init` created a nested repository between the cwd and its root. |
| 170 | const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( |
| 171 | function* (input) { |
| 172 | const requestedKind = yield* projectConfig.resolveKind(input); |
| 173 | const key = detectionCacheKey({ cwd: input.cwd, requestedKind }); |
| 174 | const cached = yield* Cache.get(detectionCache, key); |
| 175 | if (cached === null) return null; |
| 176 | const disk = yield* readDiskState(input.cwd, cached.handle.repository); |
| 177 | if ( |
| 178 | disk?.repositoryOnDisk && |
| 179 | disk.gitEntries.length === cached.gitEntries.length && |
| 180 | disk.gitEntries.every((exists, index) => exists === cached.gitEntries[index]) |
| 181 | ) { |
| 182 | return cached.handle; |
| 183 | } |
| 184 | yield* Cache.invalidate(detectionCache, key); |
| 185 | return (yield* Cache.get(detectionCache, key))?.handle ?? null; |
| 186 | }, |
| 187 | ); |
| 188 | |
| 189 | const resolve: VcsDriverRegistry["Service"]["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( |
| 190 | function* (input) { |
| 191 | const detected = yield* detect(input); |
| 192 | if (detected) { |
| 193 | return detected; |
| 194 | } |
| 195 | |
| 196 | const requestedKind = input.requestedKind ?? "auto"; |
| 197 | return yield* new VcsUnsupportedOperationError({ |
| 198 | operation: "VcsDriverRegistry.resolve", |
| 199 | kind: requestedKind === "auto" ? "unknown" : requestedKind, |
| 200 | detail: |
| 201 | requestedKind === "auto" |
| 202 | ? `No supported VCS repository was detected at ${input.cwd}.` |
| 203 | : `No ${requestedKind} repository was detected at ${input.cwd}.`, |
| 204 | }); |
| 205 | }, |
| 206 | ); |
| 207 | |
| 208 | return VcsDriverRegistry.of({ |
| 209 | get, |
| 210 | detect, |
| 211 | resolve, |
| 212 | }); |
| 213 | }); |
| 214 | |
| 215 | export const layer = Layer.effect(VcsDriverRegistry, make).pipe( |
| 216 | Layer.provide(VcsProjectConfig.layer), |
| 217 | ); |