@cvr/stacked 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ import { existsSync } from "node:fs";
2
+ import { Effect, Layer, Option } from "effect";
3
+ import { openRepository, type Repository, type SignaturePayload } from "es-git";
4
+ import { GitError } from "../errors/index.js";
5
+ import { GitService } from "./Git.js";
6
+
7
+ const makeGitError = (command: string, error: unknown) =>
8
+ new GitError({
9
+ message: error instanceof Error ? error.message : String(error),
10
+ command,
11
+ });
12
+
13
+ const resolveSignature = (repo: Repository): SignaturePayload => {
14
+ const config = repo.config();
15
+ const name =
16
+ process.env["GIT_COMMITTER_NAME"] ??
17
+ process.env["GIT_AUTHOR_NAME"] ??
18
+ config.findString("user.name");
19
+ const email =
20
+ process.env["GIT_COMMITTER_EMAIL"] ??
21
+ process.env["GIT_AUTHOR_EMAIL"] ??
22
+ config.findString("user.email");
23
+
24
+ if (name === null || name === undefined || email === null || email === undefined) {
25
+ throw makeGitError("es-git.signature", "Missing git user.name or user.email");
26
+ }
27
+
28
+ return { name, email };
29
+ };
30
+
31
+ const refName = (name: string) => (name.startsWith("refs/") ? name : `refs/heads/${name}`);
32
+
33
+ const resolveOid = (repo: Repository, ref: string): string => {
34
+ if (ref === "--absolute-git-dir" || ref === "--git-dir") {
35
+ return repo.path();
36
+ }
37
+
38
+ return repo.revparseSingle(ref);
39
+ };
40
+
41
+ const performRebase = (
42
+ repo: Repository,
43
+ branchRef: string,
44
+ upstreamRef: string,
45
+ ontoRef: string,
46
+ ): void => {
47
+ const branch = repo.getAnnotatedCommitFromReference(repo.getReference(branchRef));
48
+ const upstream = repo.getAnnotatedCommit(repo.getCommit(resolveOid(repo, upstreamRef)));
49
+ const onto = repo.getAnnotatedCommit(repo.getCommit(resolveOid(repo, ontoRef)));
50
+ const signature = resolveSignature(repo);
51
+ const rebase = repo.rebase(branch, upstream, onto);
52
+ while (true) {
53
+ const operation = rebase.next();
54
+ if (operation === null) break;
55
+ rebase.commit({ committer: signature });
56
+ }
57
+
58
+ rebase.finish(signature);
59
+ };
60
+
61
+ export const GitEsLayer = Layer.effect(
62
+ GitService,
63
+ Effect.gen(function* () {
64
+ const cwd = process.cwd();
65
+ const repo = yield* Effect.tryPromise({
66
+ try: () => openRepository(cwd),
67
+ catch: (error) => makeGitError(`es-git.openRepository ${cwd}`, error),
68
+ });
69
+
70
+ return {
71
+ currentBranch: () =>
72
+ Effect.try({
73
+ try: () => {
74
+ const branch = repo.head().shorthand();
75
+ if (branch === "HEAD") {
76
+ throw makeGitError(
77
+ "es-git.currentBranch",
78
+ "HEAD is detached — checkout a branch first",
79
+ );
80
+ }
81
+ return branch;
82
+ },
83
+ catch: (error) => makeGitError("es-git.currentBranch", error),
84
+ }),
85
+
86
+ listBranches: () =>
87
+ Effect.try({
88
+ try: () =>
89
+ [...repo.branches({ type: "Local" })]
90
+ .map(({ name }) => ({
91
+ name,
92
+ commitTime: repo
93
+ .getCommit(resolveOid(repo, refName(name)))
94
+ .time()
95
+ .getTime(),
96
+ }))
97
+ .sort((a, b) => b.commitTime - a.commitTime)
98
+ .map(({ name }) => name),
99
+ catch: (error) => makeGitError("es-git.listBranches", error),
100
+ }),
101
+
102
+ branchExists: (name) =>
103
+ Effect.try({
104
+ try: () => repo.findBranch(name, "Local") !== null,
105
+ catch: (error) => makeGitError(`es-git.branchExists ${name}`, error),
106
+ }),
107
+
108
+ remoteDefaultBranch: (remote = "origin") =>
109
+ Effect.try({
110
+ try: () => {
111
+ const ref = repo.findReference(`refs/remotes/${remote}/HEAD`);
112
+ if (ref === null) return Option.none();
113
+ const target = ref.resolve().shorthand();
114
+ const prefix = `${remote}/`;
115
+ return Option.some(target.startsWith(prefix) ? target.slice(prefix.length) : target);
116
+ },
117
+ catch: (error) => makeGitError(`es-git.remoteDefaultBranch ${remote}`, error),
118
+ }).pipe(Effect.catchTag("GitError", () => Effect.succeed(Option.none()))),
119
+
120
+ createBranch: (name, from) =>
121
+ Effect.try({
122
+ try: () => {
123
+ const commit = repo.getCommit(resolveOid(repo, from ?? "HEAD"));
124
+ repo.createBranch(name, commit);
125
+ repo.setHead(refName(name));
126
+ repo.checkoutHead();
127
+ },
128
+ catch: (error) => makeGitError(`es-git.createBranch ${name}`, error),
129
+ }).pipe(Effect.asVoid),
130
+
131
+ deleteBranch: (name, force) => {
132
+ // When force is false/undefined, shell out to `git branch -d` so git
133
+ // enforces "must be fully merged" check. es-git has no equivalent.
134
+ if (force !== true) {
135
+ return Effect.tryPromise({
136
+ try: async () => {
137
+ const proc = Bun.spawn(["git", "branch", "-d", "--", name], {
138
+ stdout: "pipe",
139
+ stderr: "pipe",
140
+ });
141
+ const exitCode = await proc.exited;
142
+ if (exitCode !== 0) {
143
+ const stderr = await new Response(proc.stderr).text();
144
+ throw new Error(stderr.trim() || `git branch -d failed with exit code ${exitCode}`);
145
+ }
146
+ },
147
+ catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
148
+ }).pipe(Effect.asVoid);
149
+ }
150
+ return Effect.try({
151
+ try: () => {
152
+ repo.getBranch(name, "Local").delete();
153
+ },
154
+ catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
155
+ }).pipe(Effect.asVoid);
156
+ },
157
+
158
+ checkout: (name) =>
159
+ Effect.try({
160
+ try: () => {
161
+ repo.setHead(refName(name));
162
+ repo.checkoutHead();
163
+ },
164
+ catch: (error) => makeGitError(`es-git.checkout ${name}`, error),
165
+ }).pipe(Effect.asVoid),
166
+
167
+ rebase: (onto) =>
168
+ Effect.try({
169
+ try: () => {
170
+ const headRef = repo.head().resolve();
171
+ const headOid = headRef.target();
172
+ if (headOid === null) {
173
+ throw makeGitError(`es-git.rebase ${onto}`, "HEAD has no target");
174
+ }
175
+
176
+ const ontoOid = resolveOid(repo, onto);
177
+ const upstreamOid = repo.getMergeBase(headOid, ontoOid);
178
+ performRebase(repo, headRef.name(), upstreamOid, onto);
179
+ },
180
+ catch: (error) => makeGitError(`es-git.rebase ${onto}`, error),
181
+ }).pipe(Effect.asVoid),
182
+
183
+ rebaseOnto: (branch, newBase, oldBase) =>
184
+ Effect.try({
185
+ try: () => {
186
+ performRebase(repo, refName(branch), oldBase, newBase);
187
+ },
188
+ catch: (error) =>
189
+ makeGitError(`es-git.rebaseOnto ${branch} ${newBase} ${oldBase}`, error),
190
+ }).pipe(Effect.asVoid),
191
+
192
+ rebaseAbort: () =>
193
+ Effect.try({
194
+ try: () => {
195
+ repo.openRebase().abort();
196
+ },
197
+ catch: (error) => makeGitError("es-git.rebaseAbort", error),
198
+ }).pipe(Effect.asVoid),
199
+
200
+ push: (branch, options) => {
201
+ // es-git's protocol has no --force-with-lease equivalent; shell out to git CLI
202
+ if (options?.force === true) {
203
+ return Effect.tryPromise({
204
+ try: async () => {
205
+ const proc = Bun.spawn(
206
+ ["git", "push", "--force-with-lease", "-u", "origin", branch],
207
+ { stdout: "pipe", stderr: "pipe" },
208
+ );
209
+ const exitCode = await proc.exited;
210
+ if (exitCode !== 0) {
211
+ const stderr = await new Response(proc.stderr).text();
212
+ throw new Error(stderr.trim() || `git push failed with exit code ${exitCode}`);
213
+ }
214
+ },
215
+ catch: (error) => makeGitError(`es-git.push ${branch}`, error),
216
+ }).pipe(Effect.asVoid);
217
+ }
218
+ return Effect.tryPromise({
219
+ try: async () => {
220
+ const remote = repo.getRemote("origin");
221
+ await remote.push([`refs/heads/${branch}:refs/heads/${branch}`]);
222
+ const localBranch = repo.findBranch(branch, "Local");
223
+ localBranch?.setUpstream(`origin/${branch}`);
224
+ },
225
+ catch: (error) => makeGitError(`es-git.push ${branch}`, error),
226
+ }).pipe(Effect.asVoid);
227
+ },
228
+
229
+ log: (branch, options) =>
230
+ Effect.try({
231
+ try: () => {
232
+ const revwalk = repo.revwalk();
233
+ revwalk.push(resolveOid(repo, branch));
234
+
235
+ const lines: string[] = [];
236
+ while (lines.length < (options?.limit ?? Number.POSITIVE_INFINITY)) {
237
+ const oid = revwalk.next();
238
+ if (oid === null) break;
239
+
240
+ if (options?.oneline === true) {
241
+ const summary = repo.getCommit(oid).summary() ?? "";
242
+ lines.push(`${oid.slice(0, 7)} ${summary}`.trim());
243
+ } else {
244
+ lines.push(oid);
245
+ }
246
+ }
247
+
248
+ return lines.join("\n");
249
+ },
250
+ catch: (error) => makeGitError(`es-git.log ${branch}`, error),
251
+ }),
252
+
253
+ isClean: () =>
254
+ Effect.try({
255
+ try: () => repo.statuses().isEmpty(),
256
+ catch: (error) => makeGitError("es-git.isClean", error),
257
+ }),
258
+
259
+ revParse: (ref) =>
260
+ Effect.try({
261
+ try: () => resolveOid(repo, ref),
262
+ catch: (error) => makeGitError(`es-git.revParse ${ref}`, error),
263
+ }),
264
+
265
+ isAncestor: (ancestor, descendant) =>
266
+ Effect.try({
267
+ try: () =>
268
+ repo.getMergeBase(resolveOid(repo, ancestor), resolveOid(repo, descendant)) ===
269
+ resolveOid(repo, ancestor),
270
+ catch: (error) => makeGitError(`es-git.isAncestor ${ancestor} ${descendant}`, error),
271
+ }).pipe(Effect.catchTag("GitError", () => Effect.succeed(false))),
272
+
273
+ mergeBase: (a, b) =>
274
+ Effect.try({
275
+ try: () => repo.getMergeBase(resolveOid(repo, a), resolveOid(repo, b)),
276
+ catch: (error) => makeGitError(`es-git.mergeBase ${a} ${b}`, error),
277
+ }),
278
+
279
+ firstParentUniqueCommits: (ref, base, options) =>
280
+ Effect.try({
281
+ try: () => {
282
+ const revwalk = repo.revwalk();
283
+ revwalk.simplifyFirstParent();
284
+ revwalk.push(resolveOid(repo, ref));
285
+ revwalk.hide(resolveOid(repo, base));
286
+
287
+ const commits: string[] = [];
288
+ const limit = options?.limit ?? Number.POSITIVE_INFINITY;
289
+ while (commits.length < limit) {
290
+ const oid = revwalk.next();
291
+ if (oid === null) break;
292
+ commits.push(oid);
293
+ }
294
+
295
+ return commits;
296
+ },
297
+ catch: (error) => makeGitError(`es-git.firstParentUniqueCommits ${ref} ${base}`, error),
298
+ }),
299
+
300
+ isRebaseInProgress: () =>
301
+ Effect.try({
302
+ try: () => {
303
+ const state = repo.state();
304
+ if (state === "Rebase" || state === "RebaseInteractive" || state === "RebaseMerge") {
305
+ return true;
306
+ }
307
+
308
+ const gitDir = repo.path();
309
+ return existsSync(`${gitDir}/rebase-merge`) || existsSync(`${gitDir}/rebase-apply`);
310
+ },
311
+ catch: (error) => makeGitError("es-git.isRebaseInProgress", error),
312
+ }).pipe(Effect.catchTag("GitError", () => Effect.succeed(false))),
313
+
314
+ commitAmend: (options) => {
315
+ // --edit requires an interactive editor; shell out to git CLI
316
+ if (options?.edit === true) {
317
+ return Effect.tryPromise({
318
+ try: async () => {
319
+ const proc = Bun.spawn(["git", "commit", "--amend"], {
320
+ stdin: "inherit",
321
+ stdout: "inherit",
322
+ stderr: "inherit",
323
+ });
324
+ const exitCode = await proc.exited;
325
+ if (exitCode !== 0) {
326
+ throw new Error(`git commit --amend failed with exit code ${exitCode}`);
327
+ }
328
+ },
329
+ catch: (error) => makeGitError("es-git.commitAmend", error),
330
+ }).pipe(Effect.asVoid);
331
+ }
332
+ return Effect.try({
333
+ try: () => {
334
+ const headOid = repo.head().target();
335
+ if (headOid === null) {
336
+ throw makeGitError("es-git.commitAmend", "HEAD has no target");
337
+ }
338
+ repo.getCommit(headOid).amend({
339
+ updateRef: "HEAD",
340
+ committer: resolveSignature(repo),
341
+ });
342
+ },
343
+ catch: (error) => makeGitError("es-git.commitAmend", error),
344
+ }).pipe(Effect.asVoid);
345
+ },
346
+
347
+ fetch: (remote = "origin") =>
348
+ Effect.tryPromise({
349
+ try: async () => {
350
+ await repo.getRemote(remote).fetch([]);
351
+ },
352
+ catch: (error) => makeGitError(`es-git.fetch ${remote}`, error),
353
+ }).pipe(Effect.asVoid),
354
+
355
+ deleteRemoteBranch: (branch) =>
356
+ Effect.tryPromise({
357
+ try: async () => {
358
+ await repo.getRemote("origin").push([`:refs/heads/${branch}`]);
359
+ },
360
+ catch: (error) => makeGitError(`es-git.deleteRemoteBranch ${branch}`, error),
361
+ }).pipe(Effect.asVoid),
362
+ };
363
+ }),
364
+ );