@cvr/stacked 0.4.2 → 0.4.4

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.
@@ -1,8 +1,10 @@
1
1
  import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Console, Effect, Option } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
+ import { GitHubService } from "../services/GitHub.js";
4
5
  import { StackService } from "../services/Stack.js";
5
6
  import { ErrorCode, StackError } from "../errors/index.js";
7
+ import { refreshStackedPRBodies } from "./helpers/pr-metadata.js";
6
8
  import { withSpinner, success, warn } from "../ui.js";
7
9
 
8
10
  const trunkFlag = Flag.string("trunk").pipe(
@@ -43,6 +45,7 @@ export const sync = Command.make("sync", {
43
45
  Command.withHandler(({ trunk: trunkOpt, from: fromOpt, json, dryRun }) =>
44
46
  Effect.gen(function* () {
45
47
  const git = yield* GitService;
48
+ const gh = yield* GitHubService;
46
49
  const stacks = yield* StackService;
47
50
 
48
51
  const trunk = Option.isSome(trunkOpt) ? trunkOpt.value : yield* stacks.getTrunk();
@@ -116,21 +119,21 @@ export const sync = Command.make("sync", {
116
119
  return;
117
120
  }
118
121
 
119
- yield* withSpinner(`Fetching ${trunk}`, git.fetch());
120
- yield* git.checkout(trunk);
121
- yield* withSpinner(`Rebasing ${trunk} onto ${originTrunk}`, git.rebase(originTrunk)).pipe(
122
- Effect.catchTag("GitError", (e) =>
123
- Effect.fail(
124
- new StackError({
125
- code: ErrorCode.REBASE_CONFLICT,
126
- message: `Rebase conflict on ${trunk}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
127
- }),
122
+ yield* Effect.gen(function* () {
123
+ yield* withSpinner(`Fetching ${trunk}`, git.fetch());
124
+ yield* git.checkout(trunk);
125
+ yield* withSpinner(`Rebasing ${trunk} onto ${originTrunk}`, git.rebase(originTrunk)).pipe(
126
+ Effect.catchTag("GitError", (e) =>
127
+ Effect.fail(
128
+ new StackError({
129
+ code: ErrorCode.REBASE_CONFLICT,
130
+ message: `Rebase conflict on ${trunk}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
131
+ }),
132
+ ),
128
133
  ),
129
- ),
130
- );
131
- results.push({ name: trunk, action: "rebased", base: originTrunk });
134
+ );
135
+ results.push({ name: trunk, action: "rebased", base: originTrunk });
132
136
 
133
- yield* Effect.gen(function* () {
134
137
  for (let i = startIdx; i < branches.length; i++) {
135
138
  const branch = branches[i];
136
139
  if (branch === undefined) continue;
@@ -172,6 +175,23 @@ export const sync = Command.make("sync", {
172
175
  ),
173
176
  );
174
177
 
178
+ const ghInstalled = yield* gh.isGhInstalled();
179
+ if (ghInstalled) {
180
+ const prMap = yield* refreshStackedPRBodies({
181
+ branches,
182
+ stackName: result.name,
183
+ gh,
184
+ });
185
+ const mergedBranches = branches.filter(
186
+ (branch) => (prMap.get(branch)?.state ?? "") === "MERGED",
187
+ );
188
+ const activeBranches = branches.filter(
189
+ (branch) => (prMap.get(branch)?.state ?? "") !== "MERGED",
190
+ );
191
+ yield* stacks.markMergedBranches(mergedBranches);
192
+ yield* stacks.unmarkMergedBranches(activeBranches);
193
+ }
194
+
175
195
  if (json) {
176
196
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
177
197
  yield* Console.log(JSON.stringify({ branches: results }, null, 2));
package/src/main.ts CHANGED
@@ -3,7 +3,7 @@ import { Command } from "effect/unstable/cli";
3
3
  import { BunRuntime, BunServices } from "@effect/platform-bun";
4
4
  import { Console, Effect, Layer } from "effect";
5
5
  import { command } from "./commands/index.js";
6
- import { GitService } from "./services/Git.js";
6
+ import { gitBackendConfig, gitServiceLayerForBackend } from "./services/git-backend.js";
7
7
  import { StackService } from "./services/Stack.js";
8
8
  import { GitHubService } from "./services/GitHub.js";
9
9
  import { OutputConfig } from "./ui.js";
@@ -41,13 +41,6 @@ const cli = Command.run(command, {
41
41
  version,
42
42
  });
43
43
 
44
- const ServiceLayer = StackService.layer.pipe(
45
- Layer.provideMerge(GitService.layer),
46
- Layer.provideMerge(GitHubService.layer),
47
- );
48
-
49
- const AppLayer = Layer.mergeAll(ServiceLayer, BunServices.layer);
50
-
51
44
  // Usage errors (bad args, invalid state) → exit 2
52
45
  // Operational errors (git/gh failures) → exit 1
53
46
  const usageCodes = new Set([
@@ -73,25 +66,34 @@ const handleKnownError = (e: { message: string; code?: string | undefined }) =>
73
66
 
74
67
  // @effect-diagnostics-next-line effect/strictEffectProvide:off
75
68
  BunRuntime.runMain(
76
- preflight.pipe(
77
- Effect.andThen(cli),
78
- Effect.provideService(OutputConfig, { verbose: isVerbose, quiet: isQuiet, yes: isYes }),
79
- Effect.provide(AppLayer),
80
- Effect.catchTags({
81
- GitError: (e) => handleKnownError(e),
82
- StackError: (e) => handleKnownError(e),
83
- GitHubError: (e) => handleKnownError(e),
84
- }),
85
- Effect.catchIf(
86
- (e): e is GlobalFlagConflictError => e instanceof GlobalFlagConflictError,
87
- Effect.fail,
88
- (e) => {
89
- const msg =
90
- e !== null && typeof e === "object" && "message" in e
91
- ? String(e.message)
92
- : JSON.stringify(e, null, 2);
93
- return handleKnownError({ message: `Unexpected error: ${msg}` });
94
- },
95
- ),
96
- ),
69
+ Effect.gen(function* () {
70
+ const gitBackend = yield* gitBackendConfig;
71
+ const serviceLayer = StackService.layer.pipe(
72
+ Layer.provideMerge(gitServiceLayerForBackend(gitBackend)),
73
+ Layer.provideMerge(GitHubService.layer),
74
+ );
75
+ const appLayer = Layer.mergeAll(serviceLayer, BunServices.layer);
76
+
77
+ yield* preflight.pipe(
78
+ Effect.andThen(cli),
79
+ Effect.provideService(OutputConfig, { verbose: isVerbose, quiet: isQuiet, yes: isYes }),
80
+ Effect.provide(appLayer),
81
+ Effect.catchTags({
82
+ GitError: (e) => handleKnownError(e),
83
+ StackError: (e) => handleKnownError(e),
84
+ GitHubError: (e) => handleKnownError(e),
85
+ }),
86
+ Effect.catchIf(
87
+ (e): e is GlobalFlagConflictError => e instanceof GlobalFlagConflictError,
88
+ Effect.fail,
89
+ (e) => {
90
+ const msg =
91
+ e !== null && typeof e === "object" && "message" in e
92
+ ? String(e.message)
93
+ : JSON.stringify(e, null, 2);
94
+ return handleKnownError({ message: `Unexpected error: ${msg}` });
95
+ },
96
+ ),
97
+ );
98
+ }),
97
99
  );
@@ -28,6 +28,11 @@ export class GitService extends ServiceMap.Service<
28
28
  readonly revParse: (ref: string) => Effect.Effect<string, GitError>;
29
29
  readonly isAncestor: (ancestor: string, descendant: string) => Effect.Effect<boolean, GitError>;
30
30
  readonly mergeBase: (a: string, b: string) => Effect.Effect<string, GitError>;
31
+ readonly firstParentUniqueCommits: (
32
+ ref: string,
33
+ base: string,
34
+ options?: { limit?: number },
35
+ ) => Effect.Effect<readonly string[], GitError>;
31
36
  readonly isRebaseInProgress: () => Effect.Effect<boolean>;
32
37
  readonly commitAmend: (options?: { edit?: boolean }) => Effect.Effect<void, GitError>;
33
38
  readonly fetch: (remote?: string) => Effect.Effect<void, GitError>;
@@ -163,6 +168,20 @@ export class GitService extends ServiceMap.Service<
163
168
 
164
169
  mergeBase: (a, b) => run(["merge-base", a, b]),
165
170
 
171
+ firstParentUniqueCommits: (ref, base, options) => {
172
+ const args = ["rev-list", "--first-parent"];
173
+ if (options?.limit !== undefined) args.push("--max-count", `${options.limit}`);
174
+ args.push(ref, `^${base}`);
175
+ return run(args).pipe(
176
+ Effect.map((output) =>
177
+ output
178
+ .split("\n")
179
+ .map((line) => line.trim())
180
+ .filter((line) => line.length > 0),
181
+ ),
182
+ );
183
+ },
184
+
166
185
  isRebaseInProgress: () =>
167
186
  run(["rev-parse", "--git-dir"]).pipe(
168
187
  Effect.map(
@@ -203,6 +222,7 @@ export class GitService extends ServiceMap.Service<
203
222
  revParse: () => Effect.succeed("abc123"),
204
223
  isAncestor: () => Effect.succeed(true),
205
224
  mergeBase: () => Effect.succeed("abc123"),
225
+ firstParentUniqueCommits: () => Effect.succeed([]),
206
226
  isRebaseInProgress: () => Effect.succeed(false),
207
227
  commitAmend: () => Effect.void,
208
228
  fetch: () => Effect.void,
@@ -0,0 +1,309 @@
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
+ return Option.some(target.replace(new RegExp(`^${remote}/`), ""));
115
+ },
116
+ catch: (error) => makeGitError(`es-git.remoteDefaultBranch ${remote}`, error),
117
+ }).pipe(Effect.catchTag("GitError", () => Effect.succeed(Option.none()))),
118
+
119
+ createBranch: (name, from) =>
120
+ Effect.try({
121
+ try: () => {
122
+ const commit = repo.getCommit(resolveOid(repo, from ?? "HEAD"));
123
+ repo.createBranch(name, commit);
124
+ repo.setHead(refName(name));
125
+ repo.checkoutHead();
126
+ },
127
+ catch: (error) => makeGitError(`es-git.createBranch ${name}`, error),
128
+ }).pipe(Effect.asVoid),
129
+
130
+ deleteBranch: (name) =>
131
+ Effect.try({
132
+ try: () => {
133
+ repo.getBranch(name, "Local").delete();
134
+ },
135
+ catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
136
+ }).pipe(Effect.asVoid),
137
+
138
+ checkout: (name) =>
139
+ Effect.try({
140
+ try: () => {
141
+ repo.setHead(refName(name));
142
+ repo.checkoutHead();
143
+ },
144
+ catch: (error) => makeGitError(`es-git.checkout ${name}`, error),
145
+ }).pipe(Effect.asVoid),
146
+
147
+ rebase: (onto) =>
148
+ Effect.try({
149
+ try: () => {
150
+ const headRef = repo.head().resolve();
151
+ const headOid = headRef.target();
152
+ if (headOid === null) {
153
+ throw makeGitError(`es-git.rebase ${onto}`, "HEAD has no target");
154
+ }
155
+
156
+ const ontoOid = resolveOid(repo, onto);
157
+ const upstreamOid = repo.getMergeBase(headOid, ontoOid);
158
+ performRebase(repo, headRef.name(), upstreamOid, onto);
159
+ },
160
+ catch: (error) => makeGitError(`es-git.rebase ${onto}`, error),
161
+ }).pipe(Effect.asVoid),
162
+
163
+ rebaseOnto: (branch, newBase, oldBase) =>
164
+ Effect.try({
165
+ try: () => {
166
+ performRebase(repo, refName(branch), oldBase, newBase);
167
+ },
168
+ catch: (error) =>
169
+ makeGitError(`es-git.rebaseOnto ${branch} ${newBase} ${oldBase}`, error),
170
+ }).pipe(Effect.asVoid),
171
+
172
+ rebaseAbort: () =>
173
+ Effect.try({
174
+ try: () => {
175
+ repo.openRebase().abort();
176
+ },
177
+ catch: (error) => makeGitError("es-git.rebaseAbort", error),
178
+ }).pipe(Effect.asVoid),
179
+
180
+ push: (branch, options) =>
181
+ Effect.tryPromise({
182
+ try: async () => {
183
+ const remote = repo.getRemote("origin");
184
+ const forcePrefix = options?.force === true ? "+" : "";
185
+ await remote.push([`${forcePrefix}refs/heads/${branch}:refs/heads/${branch}`]);
186
+ const localBranch = repo.findBranch(branch, "Local");
187
+ localBranch?.setUpstream(`origin/${branch}`);
188
+ },
189
+ catch: (error) => makeGitError(`es-git.push ${branch}`, error),
190
+ }).pipe(Effect.asVoid),
191
+
192
+ log: (branch, options) =>
193
+ Effect.try({
194
+ try: () => {
195
+ const revwalk = repo.revwalk();
196
+ revwalk.push(resolveOid(repo, branch));
197
+
198
+ const lines: string[] = [];
199
+ while (lines.length < (options?.limit ?? Number.POSITIVE_INFINITY)) {
200
+ const oid = revwalk.next();
201
+ if (oid === null) break;
202
+
203
+ if (options?.oneline === true) {
204
+ const summary = repo.getCommit(oid).summary() ?? "";
205
+ lines.push(`${oid.slice(0, 7)} ${summary}`.trim());
206
+ } else {
207
+ lines.push(oid);
208
+ }
209
+ }
210
+
211
+ return lines.join("\n");
212
+ },
213
+ catch: (error) => makeGitError(`es-git.log ${branch}`, error),
214
+ }),
215
+
216
+ isClean: () =>
217
+ Effect.try({
218
+ try: () => repo.statuses().isEmpty(),
219
+ catch: (error) => makeGitError("es-git.isClean", error),
220
+ }),
221
+
222
+ revParse: (ref) =>
223
+ Effect.try({
224
+ try: () => resolveOid(repo, ref),
225
+ catch: (error) => makeGitError(`es-git.revParse ${ref}`, error),
226
+ }),
227
+
228
+ isAncestor: (ancestor, descendant) =>
229
+ Effect.try({
230
+ try: () =>
231
+ repo.getMergeBase(resolveOid(repo, ancestor), resolveOid(repo, descendant)) ===
232
+ resolveOid(repo, ancestor),
233
+ catch: (error) => makeGitError(`es-git.isAncestor ${ancestor} ${descendant}`, error),
234
+ }).pipe(Effect.catchTag("GitError", () => Effect.succeed(false))),
235
+
236
+ mergeBase: (a, b) =>
237
+ Effect.try({
238
+ try: () => repo.getMergeBase(resolveOid(repo, a), resolveOid(repo, b)),
239
+ catch: (error) => makeGitError(`es-git.mergeBase ${a} ${b}`, error),
240
+ }),
241
+
242
+ firstParentUniqueCommits: (ref, base, options) =>
243
+ Effect.try({
244
+ try: () => {
245
+ const revwalk = repo.revwalk();
246
+ revwalk.simplifyFirstParent();
247
+ revwalk.push(resolveOid(repo, ref));
248
+ revwalk.hide(resolveOid(repo, base));
249
+
250
+ const commits: string[] = [];
251
+ const limit = options?.limit ?? Number.POSITIVE_INFINITY;
252
+ while (commits.length < limit) {
253
+ const oid = revwalk.next();
254
+ if (oid === null) break;
255
+ commits.push(oid);
256
+ }
257
+
258
+ return commits;
259
+ },
260
+ catch: (error) => makeGitError(`es-git.firstParentUniqueCommits ${ref} ${base}`, error),
261
+ }),
262
+
263
+ isRebaseInProgress: () =>
264
+ Effect.try({
265
+ try: () => {
266
+ const state = repo.state();
267
+ if (state === "Rebase" || state === "RebaseInteractive" || state === "RebaseMerge") {
268
+ return true;
269
+ }
270
+
271
+ const gitDir = repo.path();
272
+ return existsSync(`${gitDir}/rebase-merge`) || existsSync(`${gitDir}/rebase-apply`);
273
+ },
274
+ catch: (error) => makeGitError("es-git.isRebaseInProgress", error),
275
+ }).pipe(Effect.catchTag("GitError", () => Effect.succeed(false))),
276
+
277
+ commitAmend: () =>
278
+ Effect.try({
279
+ try: () => {
280
+ const headOid = repo.head().target();
281
+ if (headOid === null) {
282
+ throw makeGitError("es-git.commitAmend", "HEAD has no target");
283
+ }
284
+ repo.getCommit(headOid).amend({
285
+ updateRef: "HEAD",
286
+ committer: resolveSignature(repo),
287
+ });
288
+ },
289
+ catch: (error) => makeGitError("es-git.commitAmend", error),
290
+ }).pipe(Effect.asVoid),
291
+
292
+ fetch: (remote = "origin") =>
293
+ Effect.tryPromise({
294
+ try: async () => {
295
+ await repo.getRemote(remote).fetch([]);
296
+ },
297
+ catch: (error) => makeGitError(`es-git.fetch ${remote}`, error),
298
+ }).pipe(Effect.asVoid),
299
+
300
+ deleteRemoteBranch: (branch) =>
301
+ Effect.tryPromise({
302
+ try: async () => {
303
+ await repo.getRemote("origin").push([`:refs/heads/${branch}`]);
304
+ },
305
+ catch: (error) => makeGitError(`es-git.deleteRemoteBranch ${branch}`, error),
306
+ }).pipe(Effect.asVoid),
307
+ };
308
+ }),
309
+ );