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// 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.
20const DETECTION_CACHE_TTL = Duration.minutes(5);
21
22export interface VcsDriverResolveInput {
23 readonly cwd: string;
24 readonly requestedKind?: VcsDriverKind | "auto";
25}
26
27export interface VcsDriverHandle {
28 readonly kind: VcsDriverKind;
29 readonly repository: VcsRepositoryIdentity;
30 readonly driver: VcsDriver.VcsDriver["Service"];
31}
32
33export 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
44function detectionCacheKey(input: {
45 readonly cwd: string;
46 readonly requestedKind: VcsDriverKind | "auto";
47}): string {
48 return `${input.requestedKind}\0${input.cwd}`;
49}
50
51function 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
68export 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
215export const layer = Layer.effect(VcsDriverRegistry, make).pipe(
216 Layer.provide(VcsProjectConfig.layer),
217);