Skip to content
Source
Sign inSign up
Open History to see the last commit

GitWorkflowService.ts

521 lines19.2 KB

1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as FileSystem from "effect/FileSystem";
5​
6import {
7 GitManagerError,
8 GitCommandError,
9 type VcsSwitchRefInput,
10 type VcsSwitchRefResult,
11 type VcsCreateRefInput,
12 type VcsCreateRefResult,
13 type VcsCreateWorktreeInput,
14 type VcsCreateWorktreeResult,
15 type VcsListRefsInput,
16 type VcsListRefsResult,
17 type GitManagerServiceError,
18 type GitPreparePullRequestThreadInput,
19 type GitPreparePullRequestThreadResult,
20 type GitPullRequestRefInput,
21 type VcsPullResult,
22 type VcsRemoveWorktreeInput,
23 type GitResolvePullRequestResult,
24 type GitRunStackedActionInput,
25 type GitRunStackedActionResult,
26 type VcsStatusInput,
27 type VcsStatusLocalResult,
28 type VcsStatusRemoteResult,
29 type VcsStatusResult,
30} from "@t3tools/contracts";
31​
32import * as GitManager from "./GitManager.ts";
33import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
34import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
35import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
36​
37export class GitWorkflowService extends Context.Service<
38 GitWorkflowService,
39 {
40 readonly repositoryRoot: (cwd: string) => Effect.Effect<string | null, GitManagerServiceError>;
41 readonly hasCommit: (input: {
42 readonly cwd: string;
43 readonly refName: string;
44 }) => Effect.Effect<boolean, GitCommandError>;
45 readonly hasRefNamed: (input: {
46 readonly cwd: string;
47 readonly refName: string;
48 }) => Effect.Effect<boolean, GitCommandError>;
49 readonly status: (
50 input: VcsStatusInput,
51 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
52 readonly localStatus: (
53 input: VcsStatusInput,
54 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
55 readonly currentBranch: (cwd: string) => Effect.Effect<string | null, GitManagerServiceError>;
56 readonly remoteStatus: (
57 input: VcsStatusInput,
58 options?: GitManager.GitRemoteStatusOptions,
59 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
60 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
61 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
62 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
63 /** The checkout's own HEAD file, inside a linked worktree's admin directory when there is one. */
64 readonly headPath: (cwd: string) => Effect.Effect<string | null, never>;
65 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
66 readonly runStackedAction: (
67 input: GitRunStackedActionInput,
68 options?: GitManager.GitRunStackedActionOptions,
69 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
70 readonly resolvePullRequest: (
71 input: GitPullRequestRefInput,
72 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
73 readonly preparePullRequestThread: (
74 input: GitPreparePullRequestThreadInput,
75 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
76 readonly listRefs: (
77 input: VcsListRefsInput,
78 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
79 readonly createWorktree: (
80 input: VcsCreateWorktreeInput,
81 options?: GitVcsDriver.CreateWorktreeOptions,
82 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
83 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
84 readonly fetchRemote: (input: {
85 readonly cwd: string;
86 readonly remoteName: string;
87 readonly refName?: string;
88 }) => Effect.Effect<void, GitCommandError>;
89 readonly remoteExists: (input: {
90 readonly cwd: string;
91 readonly remoteName: string;
92 }) => Effect.Effect<boolean, GitCommandError>;
93 readonly remoteBranchExists: (input: {
94 readonly cwd: string;
95 readonly remoteName: string;
96 readonly refName: string;
97 }) => Effect.Effect<boolean, GitCommandError>;
98 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
99 readonly resolveRemoteTrackingCommit: (input: {
100 readonly cwd: string;
101 readonly refName: string;
102 readonly fallbackRemoteName: string;
103 }) => Effect.Effect<
104 { readonly commitSha: string; readonly remoteRefName: string },
105 GitCommandError
106 >;
107 readonly resolveWorktreeBaseRemote: GitVcsDriver.GitVcsDriver["Service"]["resolveWorktreeBaseRemote"];
108 readonly fetchWorktreeBaseCommit: (input: {
109 readonly cwd: string;
110 readonly refName: string;
111 readonly remote?: GitVcsDriver.GitWorktreeBaseRemote;
112 }) => Effect.Effect<
113 { readonly commitSha: string; readonly remoteRefName: string } | null,
114 GitCommandError
115 >;
116 readonly removeWorktree: (
117 input: VcsRemoveWorktreeInput,
118 ) => Effect.Effect<void, GitCommandError>;
119 readonly pruneWorktrees: (input: {
120 readonly cwd: string;
121 }) => Effect.Effect<void, GitCommandError>;
122 readonly deleteLocalBranch: (
123 input: GitVcsDriver.GitDeleteLocalBranchInput,
124 ) => Effect.Effect<void, GitCommandError>;
125 readonly createRef: (
126 input: VcsCreateRefInput,
127 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
128 readonly switchRef: (
129 input: VcsSwitchRefInput,
130 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
131 readonly renameBranch: (input: {
132 readonly exactName?: boolean;
133 readonly cwd: string;
134 readonly oldBranch: string;
135 readonly newBranch: string;
136 }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>;
137 }
138>()("t3/git/GitWorkflowService") {}
139​
140function nonRepositoryLocalStatus(): VcsStatusLocalResult {
141 return {
142 isRepo: false,
143 hasPrimaryRemote: false,
144 isDefaultRef: false,
145 refName: null,
146 hasWorkingTreeChanges: false,
147 workingTree: {
148 files: [],
149 insertions: 0,
150 deletions: 0,
151 },
152 };
154​
155function nonRepositoryStatus(): VcsStatusResult {
156 return {
157 ...nonRepositoryLocalStatus(),
158 hasUpstream: false,
159 aheadCount: 0,
160 behindCount: 0,
161 aheadOfDefaultCount: 0,
162 pr: null,
163 };
165​
166function nonRepositoryListRefs(): VcsListRefsResult {
167 return {
168 refs: [],
169 isRepo: false,
170 hasPrimaryRemote: false,
171 nextCursor: null,
172 totalCount: 0,
173 };
175​
176/** @public */
177export const make = Effect.gen(function* () {
178 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
179 const git = yield* GitVcsDriver.GitVcsDriver;
180 const gitManager = yield* GitManager.GitManager;
181 const fileSystem = yield* FileSystem.FileSystem;
182​
183 // Nested projects and path aliases must share the checkout's branch-mutation lease.
184 const withCheckoutLease = <A, E, R>(rootPath: string, effect: Effect.Effect<A, E, R>) =>
185 fileSystem.realPath(rootPath).pipe(
186 Effect.orElseSucceed(() => rootPath),
187 Effect.flatMap((cwd) => withWorkspaceLease(cwd, effect)),
188 );
189​
190 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
191 operation: string,
192 cwd: string,
193 ) {
194 const handle = yield* registry.resolve({ cwd }).pipe(
195 Effect.mapError(
196 (cause) =>
197 new GitManagerError({
198 operation,
199 cwd,
200 detail: "Failed to resolve the VCS driver for this Git workflow.",
201 cause,
202 }),
203 ),
204 );
205 return handle;
206 });
207​
208 const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* (
209 operation: string,
210 cwd: string,
211 ) {
212 const handle = yield* registry.resolve({ cwd }).pipe(
213 Effect.mapError(
214 (cause) =>
215 new GitCommandError({
216 operation,
217 command: "vcs-route",
218 cwd,
219 detail: "Failed to resolve the VCS driver for this Git command.",
220 cause,
221 }),
222 ),
223 );
224 return handle;
225 });
226​
227 const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")(
228 function* (operation: string, cwd: string) {
229 const handle = yield* registry.detect({ cwd }).pipe(
230 Effect.mapError(
231 (cause) =>
232 new GitManagerError({
233 operation,
234 cwd,
235 detail: "Failed to detect a VCS repository for this Git workflow.",
236 cause,
237 }),
238 ),
239 );
240 if (!handle) {
241 return false;
242 }
243 return true;
244 },
245 );
246​
247 const detectGitRepositoryForCommand = Effect.fn(
248 "GitWorkflowService.detectGitRepositoryForCommand",
249 )(function* (operation: string, cwd: string) {
250 const handle = yield* registry.detect({ cwd }).pipe(
251 Effect.mapError(
252 (cause) =>
253 new GitCommandError({
254 operation,
255 command: "vcs-route",
256 cwd,
257 detail: "Failed to detect a VCS repository for this Git command.",
258 cause,
259 }),
260 ),
261 );
262 if (!handle) {
263 return false;
264 }
265 return true;
266 });
267​
268 const routeGitManager =
269 <Input extends { readonly cwd: string }, Output>(
270 operation: string,
271 run: (input: Input) => Effect.Effect<Output, GitManagerServiceError>,
272 ) =>
273 (input: Input) =>
274 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));
275​
276 return GitWorkflowService.of({
277 repositoryRoot: Effect.fn("GitWorkflowService.repositoryRoot")(function (cwd) {
278 return registry.detect({ cwd }).pipe(
279 Effect.map((handle) => (handle?.kind === "git" ? handle.repository.rootPath : null)),
280 Effect.mapError(
281 (cause) =>
282 new GitManagerError({
283 operation: "GitWorkflowService.repositoryRoot",
284 cwd,
285 detail: "Failed to detect a VCS repository for this Git workflow.",
286 cause,
287 }),
288 ),
289 );
290 }),
291 hasCommit: Effect.fn("GitWorkflowService.hasCommit")(function (input) {
292 return ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe(
293 Effect.andThen(
294 git.execute({
295 operation: "GitWorkflowService.hasCommit",
296 cwd: input.cwd,
297 args: ["rev-parse", "--verify", `${input.refName}^{commit}`],
298 allowNonZeroExit: true,
299 }),
300 ),
301 Effect.map((result) => result.exitCode === 0),
302 );
303 }),
304 hasRefNamed: Effect.fn("GitWorkflowService.hasRefNamed")(function (input) {
305 // Pattern characters would widen the ref match.
306 return /[*?[\\]/.test(input.refName)
307 ? Effect.succeed(false)
308 : ensureGitCommand("GitWorkflowService.hasRefNamed", input.cwd).pipe(
309 Effect.andThen(
310 git.execute({
311 operation: "GitWorkflowService.hasRefNamed",
312 cwd: input.cwd,
313 args: [
314 "for-each-ref",
315 "--count=1",
316 "--format=%(refname)",
317 `refs/**/${input.refName}`,
318 ],
319 }),
320 ),
321 Effect.map((result) => result.stdout.trim().length > 0),
322 );
323 }),
324 status: Effect.fn("GitWorkflowService.status")(function (input) {
325 return detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
326 Effect.flatMap((isGitRepository) =>
327 isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()),
328 ),
329 );
330 }),
331 localStatus: Effect.fn("GitWorkflowService.localStatus")(function (input) {
332 return detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(
333 Effect.flatMap((isGitRepository) =>
334 isGitRepository
335 ? gitManager.localStatus(input)
336 : Effect.succeed(nonRepositoryLocalStatus()),
337 ),
338 );
339 }),
340 currentBranch: Effect.fn("GitWorkflowService.currentBranch")(function* (cwd) {
341 if (!(yield* detectGitRepositoryForStatus("GitWorkflowService.currentBranch", cwd))) {
342 return null;
343 }
344 const result = yield* git.execute({
345 operation: "GitWorkflowService.currentBranch",
346 cwd,
347 args: ["symbolic-ref", "--quiet", "HEAD"],
348 allowNonZeroExit: true,
349 timeoutMs: 5_000,
350 });
351 if (result.exitCode === 0) {
352 return result.stdout.trim().replace(/^refs\/heads\//, "") || null;
353 }
354 if (result.exitCode === 1) return null;
355 return yield* new GitCommandError({
356 operation: "GitWorkflowService.currentBranch",
357 command: "git symbolic-ref",
358 cwd,
359 argumentCount: 3,
360 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
361 stdoutLength: result.stdout.length,
362 stderrLength: result.stderr.length,
363 detail: "Failed to read the checked-out branch.",
364 });
365 }),
366 remoteStatus: Effect.fn("GitWorkflowService.remoteStatus")(function (input, options) {
367 return detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe(
368 Effect.flatMap((isGitRepository) =>
369 isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null),
370 ),
371 );
372 }),
373 invalidateLocalStatus: gitManager.invalidateLocalStatus,
374 invalidateRemoteStatus: gitManager.invalidateRemoteStatus,
375 invalidateStatus: gitManager.invalidateStatus,
376 headPath: Effect.fn("GitWorkflowService.headPath")(function (cwd) {
377 return git
378 .execute({
379 operation: "GitWorkflowService.headPath",
380 cwd,
381 args: ["rev-parse", "--path-format=absolute", "--git-path", "HEAD"],
382 allowNonZeroExit: true,
383 })
384 .pipe(
385 Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() || null : null)),
386 Effect.orElseSucceed(() => null),
387 );
388 }),
389 pullCurrentBranch: Effect.fn("GitWorkflowService.pullCurrentBranch")(function (cwd) {
390 return ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe(
391 Effect.andThen(git.pullCurrentBranch(cwd)),
392 );
393 }),
394 runStackedAction: Effect.fn("GitWorkflowService.runStackedAction")(function (input, options) {
395 return ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe(
396 Effect.flatMap((handle) =>
397 withCheckoutLease(
398 handle.repository.rootPath,
399 gitManager.runStackedAction(input, options),
400 ),
401 ),
402 );
403 }),
404 resolvePullRequest: routeGitManager(
405 "GitWorkflowService.resolvePullRequest",
406 gitManager.resolvePullRequest,
407 ),
408 preparePullRequestThread: Effect.fn("GitWorkflowService.preparePullRequestThread")(
409 function (input) {
410 return ensureGit("GitWorkflowService.preparePullRequestThread", input.cwd).pipe(
411 Effect.flatMap((handle) =>
412 input.mode === "local"
413 ? withCheckoutLease(
414 handle.repository.rootPath,
415 gitManager.preparePullRequestThread(input),
416 )
417 : gitManager.preparePullRequestThread(input),
418 ),
419 );
420 },
421 ),
422 listRefs: Effect.fn("GitWorkflowService.listRefs")(function (input) {
423 return detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe(
424 Effect.flatMap((isGitRepository) =>
425 isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()),
426 ),
427 );
428 }),
429 createWorktree: Effect.fn("GitWorkflowService.createWorktree")(function (input, options) {
430 return ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
431 Effect.andThen(gitManager.createWorktree(input, options)),
432 );
433 }),
434 listLocalBranchNames: Effect.fn("GitWorkflowService.listLocalBranchNames")(function (cwd) {
435 return ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe(
436 Effect.andThen(git.listLocalBranchNames(cwd)),
437 );
438 }),
439 fetchRemote: Effect.fn("GitWorkflowService.fetchRemote")(function (input) {
440 return ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(
441 Effect.andThen(git.fetchRemote(input)),
442 );
443 }),
444 remoteExists: Effect.fn("GitWorkflowService.remoteExists")(function (input) {
445 return ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe(
446 Effect.andThen(git.remoteExists(input)),
447 );
448 }),
449 remoteBranchExists: Effect.fn("GitWorkflowService.remoteBranchExists")(function (input) {
450 return ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe(
451 Effect.andThen(git.remoteBranchExists(input)),
452 );
453 }),
454 resolvePrimaryRemoteName: Effect.fn("GitWorkflowService.resolvePrimaryRemoteName")(
455 function (cwd) {
456 return ensureGitCommand("GitWorkflowService.resolvePrimaryRemoteName", cwd).pipe(
457 Effect.andThen(git.resolvePrimaryRemoteName(cwd)),
458 );
459 },
460 ),
461 resolveRemoteTrackingCommit: Effect.fn("GitWorkflowService.resolveRemoteTrackingCommit")(
462 function (input) {
463 return ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe(
464 Effect.andThen(git.resolveRemoteTrackingCommit(input)),
465 );
466 },
467 ),
468 resolveWorktreeBaseRemote: Effect.fn("GitWorkflowService.resolveWorktreeBaseRemote")(
469 function (input) {
470 return ensureGitCommand("GitWorkflowService.resolveWorktreeBaseRemote", input.cwd).pipe(
471 Effect.andThen(git.resolveWorktreeBaseRemote(input)),
472 );
473 },
474 ),
475 fetchWorktreeBaseCommit: Effect.fn("GitWorkflowService.fetchWorktreeBaseCommit")(
476 function (input) {
477 return ensureGitCommand("GitWorkflowService.fetchWorktreeBaseCommit", input.cwd).pipe(
478 Effect.andThen(git.fetchWorktreeBaseCommit(input)),
479 );
480 },
481 ),
482 removeWorktree: Effect.fn("GitWorkflowService.removeWorktree")(function (input) {
483 return ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
484 Effect.andThen(git.removeWorktree(input)),
485 );
486 }),
487 pruneWorktrees: Effect.fn("GitWorkflowService.pruneWorktrees")(function (input) {
488 return ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe(
489 Effect.andThen(git.pruneWorktrees(input)),
490 );
491 }),
492 deleteLocalBranch: Effect.fn("GitWorkflowService.deleteLocalBranch")(function (input) {
493 return ensureGitCommand("GitWorkflowService.deleteLocalBranch", input.cwd).pipe(
494 Effect.andThen(git.deleteLocalBranch(input)),
495 );
496 }),
497 createRef: Effect.fn("GitWorkflowService.createRef")(function (input) {
498 return ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
499 Effect.flatMap((handle) =>
500 input.switchRef
501 ? withCheckoutLease(handle.repository.rootPath, git.createRef(input))
502 : git.createRef(input),
503 ),
504 );
505 }),
506 switchRef: Effect.fn("GitWorkflowService.switchRef")(function (input) {
507 return ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe(
508 Effect.flatMap((handle) =>
509 withCheckoutLease(handle.repository.rootPath, Effect.scoped(git.switchRef(input))),
510 ),
511 );
512 }),
513 renameBranch: Effect.fn("GitWorkflowService.renameBranch")(function (input) {
514 return ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe(
515 Effect.andThen(git.renameBranch(input)),
516 );
517 }),
518 });
519});
520​
521export const layer = Layer.effect(GitWorkflowService, make);