@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.
- package/README.md +1 -1
- package/bin/stacked +0 -0
- package/package.json +4 -1
- package/scripts/benchmark-detect.ts +308 -0
- package/scripts/benchmark-git.ts +273 -0
- package/src/commands/clean.ts +11 -8
- package/src/commands/create.ts +6 -2
- package/src/commands/delete.ts +2 -1
- package/src/commands/detect.ts +128 -37
- package/src/commands/doctor.ts +20 -12
- package/src/commands/helpers/pr-metadata.ts +139 -0
- package/src/commands/index.ts +1 -1
- package/src/commands/init.ts +19 -10
- package/src/commands/list.ts +3 -4
- package/src/commands/log.ts +12 -2
- package/src/commands/rename.ts +5 -9
- package/src/commands/reorder.ts +13 -17
- package/src/commands/split.ts +4 -28
- package/src/commands/stacks.ts +5 -7
- package/src/commands/submit.ts +48 -93
- package/src/commands/sync.ts +34 -2
- package/src/errors/index.ts +2 -0
- package/src/main.ts +46 -34
- package/src/services/Git.ts +46 -4
- package/src/services/GitEs.ts +364 -0
- package/src/services/Stack.ts +627 -192
- package/src/services/git-backend.ts +18 -0
package/src/commands/reorder.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
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(
|
|
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 "${
|
|
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
|
}),
|
package/src/commands/split.ts
CHANGED
|
@@ -35,17 +35,9 @@ export const split = Command.make("split", {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const { name: stackName, stack } = result;
|
|
38
|
-
const
|
|
39
|
-
const
|
|
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
|
-
|
|
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
|
package/src/commands/stacks.ts
CHANGED
|
@@ -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
|
|
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*
|
|
27
|
+
yield* info("No stacks");
|
|
30
28
|
}
|
|
31
29
|
return;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
if (json) {
|
|
35
|
-
const stackList = entries.map((
|
|
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
|
|
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;
|
package/src/commands/submit.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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) {
|
package/src/commands/sync.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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));
|
package/src/errors/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
66
|
-
|
|
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 =
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
(
|
|
87
|
-
Effect.
|
|
88
|
-
(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
);
|
package/src/services/Git.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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,
|