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