@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.
@@ -32,12 +32,14 @@ export const reorder = Command.make("reorder", {
32
32
 
33
33
  if (Option.isNone(before) && Option.isNone(after)) {
34
34
  return yield* new StackError({
35
+ code: ErrorCode.USAGE_ERROR,
35
36
  message: "Specify --before or --after to indicate target position",
36
37
  });
37
38
  }
38
39
 
39
40
  if (Option.isSome(before) && Option.isSome(after)) {
40
41
  return yield* new StackError({
42
+ code: ErrorCode.USAGE_ERROR,
41
43
  message: "Specify either --before or --after, not both",
42
44
  });
43
45
  }
@@ -74,28 +76,22 @@ export const reorder = Command.make("reorder", {
74
76
  });
75
77
  }
76
78
 
77
- // Remove from current position
78
- branches.splice(currentIdx, 1);
79
-
80
- // Insert at target position
81
- const newTargetIdx = branches.indexOf(target);
82
- if (Option.isSome(before)) {
83
- branches.splice(newTargetIdx, 0, branch);
84
- } else {
85
- branches.splice(newTargetIdx + 1, 0, branch);
86
- }
87
-
88
- const data = yield* stacks.load();
89
- yield* stacks.save({
90
- ...data,
91
- stacks: { ...data.stacks, [stackName]: { branches } },
79
+ const updated = yield* stacks.reorderBranch(branch, {
80
+ before: Option.getOrUndefined(before),
81
+ after: Option.getOrUndefined(after),
92
82
  });
93
83
 
94
84
  if (json) {
95
85
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
96
- yield* Console.log(JSON.stringify({ branch, stack: stackName, branches }, null, 2));
86
+ yield* Console.log(
87
+ JSON.stringify(
88
+ { branch, stack: updated.name, branches: [...updated.stack.branches] },
89
+ null,
90
+ 2,
91
+ ),
92
+ );
97
93
  } else {
98
- yield* success(`Moved "${branch}" in stack "${stackName}"`);
94
+ yield* success(`Moved "${branch}" in stack "${updated.name}"`);
99
95
  yield* warn("Run 'stacked sync' to rebase branches in new order");
100
96
  }
101
97
  }),
@@ -35,17 +35,9 @@ export const split = Command.make("split", {
35
35
  }
36
36
 
37
37
  const { name: stackName, stack } = result;
38
- const branches = [...stack.branches];
39
- const splitIdx = branches.indexOf(branch);
40
-
41
- if (splitIdx === 0) {
42
- return yield* new StackError({
43
- message: `Branch "${branch}" is at the bottom of the stack — nothing to split`,
44
- });
45
- }
46
-
47
- const below = branches.slice(0, splitIdx);
48
- const above = branches.slice(splitIdx);
38
+ const splitIdx = stack.branches.indexOf(branch);
39
+ const below = stack.branches.slice(0, splitIdx);
40
+ const above = stack.branches.slice(splitIdx);
49
41
  const newStackName = branch;
50
42
 
51
43
  if (dryRun) {
@@ -68,23 +60,7 @@ export const split = Command.make("split", {
68
60
  return;
69
61
  }
70
62
 
71
- const data = yield* stacks.load();
72
-
73
- if (data.stacks[newStackName] !== undefined) {
74
- return yield* new StackError({
75
- code: ErrorCode.STACK_EXISTS,
76
- message: `Stack "${newStackName}" already exists — choose a different split point or rename it first`,
77
- });
78
- }
79
-
80
- yield* stacks.save({
81
- ...data,
82
- stacks: {
83
- ...data.stacks,
84
- [stackName]: { branches: below },
85
- [newStackName]: { branches: above },
86
- },
87
- });
63
+ yield* stacks.splitStack(branch);
88
64
 
89
65
  if (json) {
90
66
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
@@ -2,7 +2,7 @@ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
- import { stdout } from "../ui.js";
5
+ import { stdout, info } from "../ui.js";
6
6
 
7
7
  const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
8
8
 
@@ -18,21 +18,19 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
18
18
  const stackService = yield* StackService;
19
19
 
20
20
  const currentBranch = yield* git.currentBranch();
21
- const data = yield* stackService.load();
22
-
23
- const entries = Object.entries(data.stacks);
21
+ const entries = yield* stackService.listStacks();
24
22
  if (entries.length === 0) {
25
23
  if (json) {
26
24
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
27
25
  yield* Console.log(JSON.stringify({ stacks: [] }));
28
26
  } else {
29
- yield* Console.error("No stacks");
27
+ yield* info("No stacks");
30
28
  }
31
29
  return;
32
30
  }
33
31
 
34
32
  if (json) {
35
- const stackList = entries.map(([name, stack]) => ({
33
+ const stackList = entries.map(({ name, stack }) => ({
36
34
  name,
37
35
  branches: stack.branches.length,
38
36
  current: stack.branches.includes(currentBranch),
@@ -43,7 +41,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
43
41
  }
44
42
 
45
43
  const lines: string[] = [];
46
- for (const [name, stack] of entries) {
44
+ for (const { name, stack } of entries) {
47
45
  const isCurrent = stack.branches.includes(currentBranch);
48
46
  const marker = isCurrent ? yield* stdout.green("* ") : " ";
49
47
  const label = isCurrent ? yield* stdout.bold(name) : name;
@@ -4,6 +4,11 @@ import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
  import { GitHubService } from "../services/GitHub.js";
6
6
  import { ErrorCode, StackError } from "../errors/index.js";
7
+ import {
8
+ composePRBody,
9
+ generateStackMetadata,
10
+ refreshStackedPRBodies,
11
+ } from "./helpers/pr-metadata.js";
7
12
  import { withSpinner, success } from "../ui.js";
8
13
 
9
14
  const draftFlag = Flag.boolean("draft").pipe(
@@ -38,78 +43,6 @@ interface SubmitResult {
38
43
  action: "created" | "updated" | "unchanged" | "would-create" | "would-update" | "would-unchanged";
39
44
  }
40
45
 
41
- const STACKED_MARKER_START = "<!-- stacked -->";
42
- const STACKED_MARKER_END = "<!-- /stacked -->";
43
-
44
- const generateStackMetadata = (
45
- branches: readonly string[],
46
- prMap: Map<string, { number: number; url: string; state: string } | null>,
47
- currentIdx: number,
48
- stackName: string,
49
- ): string => {
50
- const rows = branches.map((branch, i) => {
51
- const pr = prMap.get(branch) ?? null;
52
- const isCurrent = i === currentIdx;
53
- const branchCol = isCurrent ? `**\`${branch}\`**` : `\`${branch}\``;
54
- const numCol = i + 1;
55
- const numStr = isCurrent ? `**${numCol}**` : `${numCol}`;
56
-
57
- let prCol: string;
58
- if (pr === null) {
59
- prCol = "—";
60
- } else if (pr.state === "MERGED") {
61
- prCol = `[#${pr.number}](${pr.url}) ✅`;
62
- } else if (isCurrent) {
63
- prCol = `**#${pr.number} ← you are here**`;
64
- } else {
65
- prCol = `[#${pr.number}](${pr.url})`;
66
- }
67
-
68
- return `| ${numStr} | ${branchCol} | ${prCol} |`;
69
- });
70
-
71
- return [
72
- STACKED_MARKER_START,
73
- `**Stack: \`${stackName}\`** (${currentIdx + 1} of ${branches.length})`,
74
- "",
75
- "| # | Branch | PR |",
76
- "|---|--------|----|",
77
- ...rows,
78
- STACKED_MARKER_END,
79
- ].join("\n");
80
- };
81
-
82
- const composePRBody = (userBody: string | undefined, metadata: string): string => {
83
- if (userBody !== undefined) {
84
- return `${userBody}\n\n---\n\n${metadata}`;
85
- }
86
- return metadata;
87
- };
88
-
89
- const updatePRBody = (
90
- existingBody: string | undefined,
91
- userBody: string | undefined,
92
- metadata: string,
93
- ): string => {
94
- if (userBody !== undefined) {
95
- return composePRBody(userBody, metadata);
96
- }
97
-
98
- if (existingBody !== undefined) {
99
- const startIdx = existingBody.indexOf(STACKED_MARKER_START);
100
- if (startIdx !== -1) {
101
- const prefix = existingBody.substring(0, startIdx).replace(/\n*---\n*$/, "");
102
- if (prefix.trim().length > 0) {
103
- return `${prefix.trim()}\n\n---\n\n${metadata}`;
104
- }
105
- return metadata;
106
- }
107
- return `${existingBody.trim()}\n\n---\n\n${metadata}`;
108
- }
109
-
110
- return metadata;
111
- };
112
-
113
46
  const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
114
47
 
115
48
  export const submit = Command.make("submit", {
@@ -157,6 +90,17 @@ export const submit = Command.make("submit", {
157
90
  const trunk = yield* stacks.getTrunk();
158
91
  const currentBranch = yield* git.currentBranch();
159
92
  const { branches } = result.stack;
93
+ const data = yield* stacks.load();
94
+ const mergedSet = new Set(data.mergedBranches);
95
+
96
+ // Compute the effective base for a branch at index i, skipping merged branches
97
+ const effectiveBase = (i: number): string => {
98
+ for (let j = i - 1; j >= 0; j--) {
99
+ const candidate = branches[j];
100
+ if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
101
+ }
102
+ return trunk;
103
+ };
160
104
 
161
105
  const rawTitle = Option.isSome(titleOpt) ? titleOpt.value : undefined;
162
106
  const rawBody = Option.isSome(bodyOpt) ? bodyOpt.value : undefined;
@@ -173,11 +117,13 @@ export const submit = Command.make("submit", {
173
117
 
174
118
  if (titles !== undefined && titles.length !== branches.length) {
175
119
  return yield* new StackError({
120
+ code: ErrorCode.USAGE_ERROR,
176
121
  message: `--title has ${titles.length} values but stack has ${branches.length} branches`,
177
122
  });
178
123
  }
179
124
  if (bodies !== undefined && bodies.length !== branches.length) {
180
125
  return yield* new StackError({
126
+ code: ErrorCode.USAGE_ERROR,
181
127
  message: `--body has ${bodies.length} values but stack has ${branches.length} branches`,
182
128
  });
183
129
  }
@@ -199,19 +145,34 @@ export const submit = Command.make("submit", {
199
145
  const results: SubmitResult[] = [];
200
146
  const prMap = new Map<
201
147
  string,
202
- { number: number; url: string; state: string; body?: string | null } | null
148
+ { number: number; url: string; state: string; base: string; body?: string | null } | null
203
149
  >();
204
150
 
151
+ // Pre-fetch all PR statuses in parallel
152
+ const activeBranches = only ? branches.filter((b) => b === currentBranch) : [...branches];
153
+ const prResults = yield* Effect.forEach(
154
+ activeBranches,
155
+ (branch) =>
156
+ gh.getPR(branch).pipe(
157
+ Effect.map((pr) => [branch, pr] as const),
158
+ Effect.catchTag("GitHubError", () => Effect.succeed([branch, null] as const)),
159
+ ),
160
+ { concurrency: 5 },
161
+ );
162
+ for (const [branch, pr] of prResults) {
163
+ prMap.set(branch, pr);
164
+ }
165
+
205
166
  for (let i = 0; i < branches.length; i++) {
206
167
  const branch = branches[i];
207
168
  if (branch === undefined) continue;
208
- const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
169
+ const base = effectiveBase(i);
209
170
 
210
171
  // --only: skip branches that aren't current
211
172
  if (only && branch !== currentBranch) continue;
212
173
 
213
174
  if (dryRun) {
214
- const existingPR = yield* gh.getPR(branch);
175
+ const existingPR = prMap.get(branch) ?? null;
215
176
  const action =
216
177
  existingPR === null
217
178
  ? "would-create"
@@ -233,8 +194,7 @@ export const submit = Command.make("submit", {
233
194
 
234
195
  yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: !noForce }));
235
196
 
236
- const existingPR = yield* gh.getPR(branch);
237
- prMap.set(branch, existingPR);
197
+ const existingPR = prMap.get(branch) ?? null;
238
198
 
239
199
  if (existingPR !== null) {
240
200
  if (existingPR.base !== base) {
@@ -272,7 +232,7 @@ export const submit = Command.make("submit", {
272
232
  body,
273
233
  draft,
274
234
  });
275
- prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN" });
235
+ prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN", base });
276
236
  yield* success(`Created PR #${pr.number}: ${pr.url}`);
277
237
  results.push({
278
238
  branch,
@@ -294,21 +254,16 @@ export const submit = Command.make("submit", {
294
254
 
295
255
  // Update all processed PRs with complete stack metadata.
296
256
  // This includes newly created PRs so placeholders get replaced in one submit run.
297
- for (let i = 0; i < branches.length; i++) {
298
- const branch = branches[i];
299
- if (branch === undefined) continue;
300
- if (only && branch !== currentBranch) continue;
301
-
302
- const entry = results.find((x) => x.branch === branch);
303
- if (entry === undefined) continue;
304
-
305
- const metadata = generateStackMetadata(branches, prMap, i, result.name);
306
- const existingPrData = prMap.get(branch) ?? null;
307
- const existingBody = existingPrData?.body ?? undefined;
308
- const userBody = getBodyForBranch(branch, i);
309
- const body = updatePRBody(existingBody, userBody, metadata);
310
- yield* gh.updatePR({ branch, body });
311
- }
257
+ yield* refreshStackedPRBodies({
258
+ branches,
259
+ stackName: result.name,
260
+ gh,
261
+ initialPrMap: prMap,
262
+ shouldUpdateBranch: (branch) =>
263
+ (!only || branch === currentBranch) && results.some((entry) => entry.branch === branch),
264
+ getUserBody: getBodyForBranch,
265
+ });
266
+ yield* stacks.unmarkMergedBranches(branches);
312
267
 
313
268
  // Print structured output to stdout
314
269
  if (json) {
@@ -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();
@@ -69,6 +72,18 @@ export const sync = Command.make("sync", {
69
72
  }
70
73
 
71
74
  const { branches } = result.stack;
75
+ const data = yield* stacks.load();
76
+ const mergedSet = new Set(data.mergedBranches);
77
+
78
+ // Compute the effective base for a branch at index i, skipping merged branches
79
+ const effectiveBase = (i: number, fallback: string): string => {
80
+ for (let j = i - 1; j >= 0; j--) {
81
+ const candidate = branches[j];
82
+ if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
83
+ }
84
+ return fallback;
85
+ };
86
+
72
87
  const fromBranch = Option.isSome(fromOpt) ? fromOpt.value : undefined;
73
88
 
74
89
  let startIdx = 0;
@@ -98,7 +113,7 @@ export const sync = Command.make("sync", {
98
113
  for (let i = startIdx; i < branches.length; i++) {
99
114
  const branch = branches[i];
100
115
  if (branch === undefined) continue;
101
- const base = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
116
+ const base = effectiveBase(i, originTrunk);
102
117
  results.push({ name: branch, action: "skipped", base });
103
118
  if (!json) {
104
119
  yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
@@ -134,7 +149,7 @@ export const sync = Command.make("sync", {
134
149
  for (let i = startIdx; i < branches.length; i++) {
135
150
  const branch = branches[i];
136
151
  if (branch === undefined) continue;
137
- const newBase = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
152
+ const newBase = effectiveBase(i, originTrunk);
138
153
 
139
154
  // Compute old base (merge-base of this branch and its parent) before rebasing
140
155
  const oldBase = yield* git
@@ -172,6 +187,23 @@ export const sync = Command.make("sync", {
172
187
  ),
173
188
  );
174
189
 
190
+ const ghInstalled = yield* gh.isGhInstalled();
191
+ if (ghInstalled) {
192
+ const prMap = yield* refreshStackedPRBodies({
193
+ branches,
194
+ stackName: result.name,
195
+ gh,
196
+ });
197
+ const mergedBranches = branches.filter(
198
+ (branch) => (prMap.get(branch)?.state ?? "") === "MERGED",
199
+ );
200
+ const activeBranches = branches.filter(
201
+ (branch) => (prMap.get(branch)?.state ?? "") !== "MERGED",
202
+ );
203
+ yield* stacks.markMergedBranches(mergedBranches);
204
+ yield* stacks.unmarkMergedBranches(activeBranches);
205
+ }
206
+
175
207
  if (json) {
176
208
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
177
209
  yield* Console.log(JSON.stringify({ branches: results }, null, 2));
@@ -19,6 +19,8 @@ export const ErrorCode = {
19
19
  STACK_EMPTY: "STACK_EMPTY",
20
20
  TRUNK_ERROR: "TRUNK_ERROR",
21
21
  STACK_EXISTS: "STACK_EXISTS",
22
+ USAGE_ERROR: "USAGE_ERROR",
23
+ HAS_CHILDREN: "HAS_CHILDREN",
22
24
  } as const;
23
25
 
24
26
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
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";
@@ -15,11 +15,11 @@ const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
15
15
  // Global Flags (parsed before CLI framework, stripped from argv)
16
16
  // ============================================================================
17
17
 
18
- const globalFlags = new Set(["--verbose", "--quiet", "-q", "--no-color", "--yes", "-y"]);
18
+ const globalFlags = new Set(["--verbose", "-v", "--quiet", "-q", "--no-color", "--yes", "-y"]);
19
19
  const flagArgs = new Set(process.argv.filter((a) => globalFlags.has(a)));
20
20
  process.argv = process.argv.filter((a) => !globalFlags.has(a));
21
21
 
22
- const isVerbose = flagArgs.has("--verbose");
22
+ const isVerbose = flagArgs.has("--verbose") || flagArgs.has("-v");
23
23
  const isQuiet = flagArgs.has("--quiet") || flagArgs.has("-q");
24
24
  const isNoColor = flagArgs.has("--no-color");
25
25
  const isYes = flagArgs.has("--yes") || flagArgs.has("-y");
@@ -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([
@@ -60,38 +53,57 @@ const usageCodes = new Set([
60
53
  "ALREADY_AT_BOTTOM",
61
54
  "TRUNK_ERROR",
62
55
  "STACK_EXISTS",
56
+ "USAGE_ERROR",
57
+ "HAS_CHILDREN",
63
58
  ]);
64
59
 
65
- const handleKnownError = (e: { message: string; code?: string | undefined }) =>
66
- Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message).pipe(
60
+ const isJson = process.argv.includes("--json");
61
+
62
+ const handleKnownError = (e: { message: string; code?: string | undefined }) => {
63
+ const exitCode = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
64
+ const errorOutput = isJson
65
+ ? Console.log(JSON.stringify({ error: { code: e.code ?? null, message: e.message } }))
66
+ : Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message);
67
+
68
+ return errorOutput.pipe(
67
69
  Effect.andThen(
68
70
  Effect.sync(() => {
69
- process.exitCode = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
71
+ process.exitCode = exitCode;
70
72
  }),
71
73
  ),
72
74
  );
75
+ };
73
76
 
74
77
  // @effect-diagnostics-next-line effect/strictEffectProvide:off
75
78
  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
- ),
79
+ Effect.gen(function* () {
80
+ const gitBackend = yield* gitBackendConfig;
81
+ const serviceLayer = StackService.layer.pipe(
82
+ Layer.provideMerge(gitServiceLayerForBackend(gitBackend)),
83
+ Layer.provideMerge(GitHubService.layer),
84
+ );
85
+ const appLayer = Layer.mergeAll(serviceLayer, BunServices.layer);
86
+
87
+ yield* preflight.pipe(
88
+ Effect.andThen(cli),
89
+ Effect.provideService(OutputConfig, { verbose: isVerbose, quiet: isQuiet, yes: isYes }),
90
+ Effect.provide(appLayer),
91
+ Effect.catchTags({
92
+ GitError: (e) => handleKnownError(e),
93
+ StackError: (e) => handleKnownError(e),
94
+ GitHubError: (e) => handleKnownError(e),
95
+ }),
96
+ Effect.catchIf(
97
+ (e): e is GlobalFlagConflictError => e instanceof GlobalFlagConflictError,
98
+ Effect.fail,
99
+ (e) => {
100
+ const msg =
101
+ e !== null && typeof e === "object" && "message" in e
102
+ ? String(e.message)
103
+ : JSON.stringify(e, null, 2);
104
+ return handleKnownError({ message: `Unexpected error: ${msg}` });
105
+ },
106
+ ),
107
+ );
108
+ }),
97
109
  );
@@ -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>;
@@ -116,7 +121,10 @@ export class GitService extends ServiceMap.Service<
116
121
 
117
122
  remoteDefaultBranch: (remote = "origin") =>
118
123
  run(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`]).pipe(
119
- Effect.map((ref) => Option.some(ref.replace(new RegExp(`^${remote}/`), ""))),
124
+ Effect.map((ref) => {
125
+ const prefix = `${remote}/`;
126
+ return Option.some(ref.startsWith(prefix) ? ref.slice(prefix.length) : ref);
127
+ }),
120
128
  Effect.catchTag("GitError", () => Effect.succeed(Option.none())),
121
129
  ),
122
130
 
@@ -163,6 +171,20 @@ export class GitService extends ServiceMap.Service<
163
171
 
164
172
  mergeBase: (a, b) => run(["merge-base", a, b]),
165
173
 
174
+ firstParentUniqueCommits: (ref, base, options) => {
175
+ const args = ["rev-list", "--first-parent"];
176
+ if (options?.limit !== undefined) args.push("--max-count", `${options.limit}`);
177
+ args.push(ref, `^${base}`);
178
+ return run(args).pipe(
179
+ Effect.map((output) =>
180
+ output
181
+ .split("\n")
182
+ .map((line) => line.trim())
183
+ .filter((line) => line.length > 0),
184
+ ),
185
+ );
186
+ },
187
+
166
188
  isRebaseInProgress: () =>
167
189
  run(["rev-parse", "--git-dir"]).pipe(
168
190
  Effect.map(
@@ -173,9 +195,28 @@ export class GitService extends ServiceMap.Service<
173
195
  ),
174
196
 
175
197
  commitAmend: (options) => {
176
- const args = ["commit", "--amend"];
177
- if (options?.edit !== true) args.push("--no-edit");
178
- return run(args).pipe(Effect.asVoid);
198
+ if (options?.edit === true) {
199
+ // Interactive editor needs inherited stdio, not piped
200
+ return Effect.tryPromise({
201
+ try: async () => {
202
+ const proc = Bun.spawn(["git", "commit", "--amend"], {
203
+ stdin: "inherit",
204
+ stdout: "inherit",
205
+ stderr: "inherit",
206
+ });
207
+ const exitCode = await proc.exited;
208
+ if (exitCode !== 0) {
209
+ throw new Error(`git commit --amend failed with exit code ${exitCode}`);
210
+ }
211
+ },
212
+ catch: (e) =>
213
+ new GitError({
214
+ message: `Process failed: ${e}`,
215
+ command: "git commit --amend",
216
+ }),
217
+ }).pipe(Effect.asVoid);
218
+ }
219
+ return run(["commit", "--amend", "--no-edit"]).pipe(Effect.asVoid);
179
220
  },
180
221
 
181
222
  fetch: (remote) => run(["fetch", remote ?? "origin"]).pipe(Effect.asVoid),
@@ -203,6 +244,7 @@ export class GitService extends ServiceMap.Service<
203
244
  revParse: () => Effect.succeed("abc123"),
204
245
  isAncestor: () => Effect.succeed(true),
205
246
  mergeBase: () => Effect.succeed("abc123"),
247
+ firstParentUniqueCommits: () => Effect.succeed([]),
206
248
  isRebaseInProgress: () => Effect.succeed(false),
207
249
  commitAmend: () => Effect.void,
208
250
  fetch: () => Effect.void,