1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import { mergeGitStatusParts } from "@t3tools/shared/git";
7
8import {
9 GitManagerError,
10 GitCommandError,
11 type VcsSwitchRefInput,
12 type VcsSwitchRefResult,
13 type VcsCreateRefInput,
14 type VcsCreateRefResult,
15 type VcsCreateWorktreeInput,
16 type VcsCreateWorktreeResult,
17 type VcsListRefsInput,
18 type VcsListRefsResult,
19 type GitManagerServiceError,
20 type GitPreparePullRequestThreadInput,
21 type GitPreparePullRequestThreadResult,
22 type GitPullRequestRefInput,
23 type VcsPullResult,
24 type VcsRemoveWorktreeInput,
25 type GitResolvePullRequestResult,
26 type GitRunStackedActionInput,
27 type GitRunStackedActionResult,
28 type VcsStatusInput,
29 type VcsStatusLocalResult,
30 type VcsStatusRemoteResult,
31 type VcsStatusResult,
32} from "@t3tools/contracts";
33
34import * as GitManager from "./GitManager.ts";
35import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
36import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
37
38export class GitWorkflowService extends Context.Service<
39 GitWorkflowService,
40 {
41 readonly isRepository: (cwd: string) => Effect.Effect<boolean, GitManagerServiceError>;
42 readonly hasCommit: (input: {
43 readonly cwd: string;
44 readonly refName: string;
45 }) => Effect.Effect<boolean, GitCommandError>;
46 /**
47 * Whether a ref in any namespace ends in this name: a local branch, a
48 * remote-tracking branch of any remote, a tag. Git falls back to these
49 * when a branch name is not a revision by itself.
50 */
51 readonly hasRefNamed: (input: {
52 readonly cwd: string;
53 readonly refName: string;
54 }) => Effect.Effect<boolean, GitCommandError>;
55 readonly status: (
56 input: VcsStatusInput,
57 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
58 readonly localStatus: (
59 input: VcsStatusInput,
60 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
61 readonly remoteStatus: (
62 input: VcsStatusInput,
63 options?: GitManager.GitRemoteStatusOptions,
64 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
65 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
66 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
67 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
68 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
69 readonly runStackedAction: (
70 input: GitRunStackedActionInput,
71 options?: GitManager.GitRunStackedActionOptions,
72 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
73 readonly resolvePullRequest: (
74 input: GitPullRequestRefInput,
75 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
76 readonly preparePullRequestThread: (
77 input: GitPreparePullRequestThreadInput,
78 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
79 readonly listRefs: (
80 input: VcsListRefsInput,
81 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
82 readonly createWorktree: (
83 input: VcsCreateWorktreeInput,
84 options?: GitVcsDriver.CreateWorktreeOptions,
85 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
86 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
87 readonly fetchRemote: (input: {
88 readonly cwd: string;
89 readonly remoteName: string;
90 readonly refName?: string;
91 }) => Effect.Effect<void, GitCommandError>;
92 readonly remoteExists: (input: {
93 readonly cwd: string;
94 readonly remoteName: string;
95 }) => Effect.Effect<boolean, GitCommandError>;
96 readonly remoteBranchExists: (input: {
97 readonly cwd: string;
98 readonly remoteName: string;
99 readonly refName: string;
100 }) => Effect.Effect<boolean, GitCommandError>;
101 readonly resolveRemoteTrackingCommit: (input: {
102 readonly cwd: string;
103 readonly refName: string;
104 readonly fallbackRemoteName: string;
105 }) => Effect.Effect<
106 { readonly commitSha: string; readonly remoteRefName: string },
107 GitCommandError
108 >;
109 readonly removeWorktree: (
110 input: VcsRemoveWorktreeInput,
111 ) => Effect.Effect<void, GitCommandError>;
112 readonly pruneWorktrees: (input: {
113 readonly cwd: string;
114 }) => Effect.Effect<void, GitCommandError>;
115 readonly deleteLocalBranch: (
116 input: GitVcsDriver.GitDeleteLocalBranchInput,
117 ) => Effect.Effect<void, GitCommandError>;
118 readonly createRef: (
119 input: VcsCreateRefInput,
120 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
121 readonly switchRef: (
122 input: VcsSwitchRefInput,
123 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
124 readonly renameBranch: (input: {
125 readonly exactName?: boolean;
126 readonly cwd: string;
127 readonly oldBranch: string;
128 readonly newBranch: string;
129 }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>;
130 }
131>()("t3/git/GitWorkflowService") {}
132
133function nonRepositoryListRefs(): VcsListRefsResult {
134 return {
135 refs: [],
136 isRepo: false,
137 hasPrimaryRemote: false,
138 nextCursor: null,
139 totalCount: 0,
140 };
141}
142
143/** @public Service construction is part of the canonical Effect module API. */
144export const make = Effect.gen(function* () {
145 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
146 const git = yield* GitVcsDriver.GitVcsDriver;
147 const gitManager = yield* GitManager.GitManager;
148 const fileSystem = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150
151 const nonGitLocalStatus = Effect.fn("GitWorkflowService.nonGitLocalStatus")(function* (
152 cwd: string,
153 ) {
154 let directory = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd));
155 let isJjWorkspace = false;
156 while (true) {
157 const metadata = yield* fileSystem
158 .stat(path.join(directory, ".jj"))
159 .pipe(Effect.orElseSucceed(() => null));
160 if (metadata?.type === "Directory") {
161 isJjWorkspace = true;
162 break;
163 }
164 const parent = path.dirname(directory);
165 if (parent === directory) break;
166 directory = parent;
167 }
168
169 return {
170 isRepo: false,
171 ...(isJjWorkspace ? { isJjWorkspace: true } : {}),
172 hasPrimaryRemote: false,
173 isDefaultRef: false,
174 refName: null,
175 hasWorkingTreeChanges: false,
176 workingTree: { files: [], insertions: 0, deletions: 0 },
177 } satisfies VcsStatusLocalResult;
178 });
179
180 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
181 operation: string,
182 cwd: string,
183 ) {
184 const handle = yield* registry.resolve({ cwd }).pipe(
185 Effect.mapError(
186 (cause) =>
187 new GitManagerError({
188 operation,
189 cwd,
190 detail: "Failed to resolve the VCS driver for this Git workflow.",
191 cause,
192 }),
193 ),
194 );
195 if (handle.kind !== "git") {
196 return yield* new GitManagerError({
197 operation,
198 cwd,
199 detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`,
200 });
201 }
202 });
203
204 const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* (
205 operation: string,
206 cwd: string,
207 ) {
208 const handle = yield* registry.resolve({ cwd }).pipe(
209 Effect.mapError(
210 (cause) =>
211 new GitCommandError({
212 operation,
213 command: "vcs-route",
214 cwd,
215 detail: "Failed to resolve the VCS driver for this Git command.",
216 cause,
217 }),
218 ),
219 );
220 if (handle.kind !== "git") {
221 return yield* new GitCommandError({
222 operation,
223 command: "vcs-route",
224 cwd,
225 detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,
226 });
227 }
228 });
229
230 const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")(
231 function* (operation: string, cwd: string) {
232 const handle = yield* registry.detect({ cwd }).pipe(
233 Effect.mapError(
234 (cause) =>
235 new GitManagerError({
236 operation,
237 cwd,
238 detail: "Failed to detect a VCS repository for this Git workflow.",
239 cause,
240 }),
241 ),
242 );
243 if (!handle) {
244 return false;
245 }
246 if (handle.kind !== "git") {
247 return yield* new GitManagerError({
248 operation,
249 cwd,
250 detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`,
251 });
252 }
253 return true;
254 },
255 );
256
257 const detectGitRepositoryForCommand = Effect.fn(
258 "GitWorkflowService.detectGitRepositoryForCommand",
259 )(function* (operation: string, cwd: string) {
260 const handle = yield* registry.detect({ cwd }).pipe(
261 Effect.mapError(
262 (cause) =>
263 new GitCommandError({
264 operation,
265 command: "vcs-route",
266 cwd,
267 detail: "Failed to detect a VCS repository for this Git command.",
268 cause,
269 }),
270 ),
271 );
272 if (!handle) {
273 return false;
274 }
275 if (handle.kind !== "git") {
276 return yield* new GitCommandError({
277 operation,
278 command: "vcs-route",
279 cwd,
280 detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,
281 });
282 }
283 return true;
284 });
285
286 const routeGitManager =
287 <Input extends { readonly cwd: string }, Output>(
288 operation: string,
289 run: (input: Input) => Effect.Effect<Output, GitManagerServiceError>,
290 ) =>
291 (input: Input) =>
292 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));
293
294 return GitWorkflowService.of({
295 isRepository: (cwd) =>
296 registry.detect({ cwd }).pipe(
297 Effect.map((handle) => handle?.kind === "git"),
298 Effect.mapError(
299 (cause) =>
300 new GitManagerError({
301 operation: "GitWorkflowService.isRepository",
302 cwd,
303 detail: "Failed to detect a VCS repository for this Git workflow.",
304 cause,
305 }),
306 ),
307 ),
308 hasCommit: (input) =>
309 ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe(
310 Effect.andThen(
311 git.execute({
312 operation: "GitWorkflowService.hasCommit",
313 cwd: input.cwd,
314 args: ["rev-parse", "--verify", `${input.refName}^{commit}`],
315 allowNonZeroExit: true,
316 }),
317 ),
318 Effect.map((result) => result.exitCode === 0),
319 ),
320 hasRefNamed: (input) =>
321 // No ref name holds a pattern character, and one here would widen the match.
322 /[*?[\\]/.test(input.refName)
323 ? Effect.succeed(false)
324 : ensureGitCommand("GitWorkflowService.hasRefNamed", input.cwd).pipe(
325 Effect.andThen(
326 git.execute({
327 operation: "GitWorkflowService.hasRefNamed",
328 cwd: input.cwd,
329 args: [
330 "for-each-ref",
331 "--count=1",
332 "--format=%(refname)",
333 `refs/**/${input.refName}`,
334 ],
335 }),
336 ),
337 Effect.map((result) => result.stdout.trim().length > 0),
338 ),
339 status: (input) =>
340 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
341 Effect.flatMap((isGitRepository) =>
342 isGitRepository
343 ? gitManager.status(input)
344 : nonGitLocalStatus(input.cwd).pipe(
345 Effect.map((local) => mergeGitStatusParts(local, null)),
346 ),
347 ),
348 ),
349 localStatus: (input) =>
350 detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(
351 Effect.flatMap((isGitRepository) =>
352 isGitRepository ? gitManager.localStatus(input) : nonGitLocalStatus(input.cwd),
353 ),
354 ),
355 remoteStatus: (input, options) =>
356 detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe(
357 Effect.flatMap((isGitRepository) =>
358 isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null),
359 ),
360 ),
361 invalidateLocalStatus: gitManager.invalidateLocalStatus,
362 invalidateRemoteStatus: gitManager.invalidateRemoteStatus,
363 invalidateStatus: gitManager.invalidateStatus,
364 pullCurrentBranch: (cwd) =>
365 ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe(
366 Effect.andThen(git.pullCurrentBranch(cwd)),
367 ),
368 runStackedAction: (input, options) =>
369 ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe(
370 Effect.andThen(gitManager.runStackedAction(input, options)),
371 ),
372 resolvePullRequest: routeGitManager(
373 "GitWorkflowService.resolvePullRequest",
374 gitManager.resolvePullRequest,
375 ),
376 preparePullRequestThread: routeGitManager(
377 "GitWorkflowService.preparePullRequestThread",
378 gitManager.preparePullRequestThread,
379 ),
380 listRefs: (input) =>
381 detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe(
382 Effect.flatMap((isGitRepository) =>
383 isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()),
384 ),
385 ),
386 createWorktree: (input, options) =>
387 ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
388 Effect.andThen(gitManager.createWorktree(input, options)),
389 ),
390 listLocalBranchNames: (cwd) =>
391 ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe(
392 Effect.andThen(git.listLocalBranchNames(cwd)),
393 ),
394 fetchRemote: (input) =>
395 ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(
396 Effect.andThen(git.fetchRemote(input)),
397 ),
398 remoteExists: (input) =>
399 ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe(
400 Effect.andThen(git.remoteExists(input)),
401 ),
402 remoteBranchExists: (input) =>
403 ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe(
404 Effect.andThen(git.remoteBranchExists(input)),
405 ),
406 resolveRemoteTrackingCommit: (input) =>
407 ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe(
408 Effect.andThen(git.resolveRemoteTrackingCommit(input)),
409 ),
410 removeWorktree: (input) =>
411 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
412 Effect.andThen(git.removeWorktree(input)),
413 ),
414 pruneWorktrees: (input) =>
415 ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe(
416 Effect.andThen(git.pruneWorktrees(input)),
417 ),
418 deleteLocalBranch: (input) =>
419 ensureGitCommand("GitWorkflowService.deleteLocalBranch", input.cwd).pipe(
420 Effect.andThen(git.deleteLocalBranch(input)),
421 ),
422 createRef: (input) =>
423 ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
424 Effect.andThen(git.createRef(input)),
425 ),
426 switchRef: (input) =>
427 ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe(
428 Effect.andThen(Effect.scoped(git.switchRef(input))),
429 ),
430 renameBranch: (input) =>
431 ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe(
432 Effect.andThen(git.renameBranch(input)),
433 ),
434 });
435});
436
437export const layer = Layer.effect(GitWorkflowService, make);