@cvr/stacked 0.2.0 → 0.4.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 +78 -22
- package/bin/stacked +0 -0
- package/package.json +3 -2
- package/scripts/build.ts +8 -1
- package/skills/stacked/SKILL.md +221 -39
- package/src/commands/adopt.ts +70 -8
- package/src/commands/amend.ts +107 -0
- package/src/commands/bottom.ts +26 -8
- package/src/commands/checkout.ts +25 -6
- package/src/commands/clean.ts +106 -27
- package/src/commands/create.ts +74 -12
- package/src/commands/delete.ts +92 -24
- package/src/commands/detect.ts +152 -0
- package/src/commands/doctor.ts +124 -0
- package/src/commands/down.ts +62 -0
- package/src/commands/helpers/validate.ts +61 -0
- package/src/commands/index.ts +29 -1
- package/src/commands/init.ts +40 -0
- package/src/commands/list.ts +61 -22
- package/src/commands/log.ts +32 -6
- package/src/commands/rename.ts +49 -0
- package/src/commands/reorder.ts +93 -0
- package/src/commands/split.ts +108 -0
- package/src/commands/stacks.ts +33 -7
- package/src/commands/status.ts +55 -0
- package/src/commands/submit.ts +245 -16
- package/src/commands/sync.ts +127 -19
- package/src/commands/top.ts +24 -8
- package/src/commands/trunk.ts +37 -6
- package/src/commands/up.ts +55 -0
- package/src/errors/index.ts +30 -0
- package/src/global.d.ts +2 -0
- package/src/main.ts +70 -3
- package/src/services/Git.ts +102 -30
- package/src/services/GitHub.ts +56 -18
- package/src/services/Stack.ts +65 -58
- package/src/ui.ts +173 -0
package/src/commands/submit.ts
CHANGED
|
@@ -1,68 +1,297 @@
|
|
|
1
1
|
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
-
import { Console, Effect } from "effect";
|
|
2
|
+
import { Console, Effect, Option } from "effect";
|
|
3
3
|
import { GitService } from "../services/Git.js";
|
|
4
4
|
import { StackService } from "../services/Stack.js";
|
|
5
5
|
import { GitHubService } from "../services/GitHub.js";
|
|
6
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
7
|
+
import { withSpinner, success } from "../ui.js";
|
|
6
8
|
|
|
7
|
-
const draftFlag = Flag.boolean("draft").pipe(
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const draftFlag = Flag.boolean("draft").pipe(
|
|
10
|
+
Flag.withAlias("d"),
|
|
11
|
+
Flag.withDescription("Create PRs as drafts"),
|
|
12
|
+
);
|
|
13
|
+
const noForceFlag = Flag.boolean("no-force").pipe(
|
|
14
|
+
Flag.withDescription("Disable force-push (force-with-lease is on by default)"),
|
|
15
|
+
);
|
|
16
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
17
|
+
Flag.withDescription("Show what would happen without making changes"),
|
|
18
|
+
);
|
|
19
|
+
const titleFlag = Flag.string("title").pipe(
|
|
20
|
+
Flag.optional,
|
|
21
|
+
Flag.withAlias("t"),
|
|
22
|
+
Flag.withDescription(
|
|
23
|
+
"PR title (defaults to branch name). Comma-delimited for per-branch titles.",
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
const bodyFlag = Flag.string("body").pipe(
|
|
27
|
+
Flag.optional,
|
|
28
|
+
Flag.withAlias("b"),
|
|
29
|
+
Flag.withDescription("PR body/description. Comma-delimited for per-branch bodies."),
|
|
30
|
+
);
|
|
31
|
+
const onlyFlag = Flag.boolean("only").pipe(Flag.withDescription("Only submit the current branch"));
|
|
32
|
+
|
|
33
|
+
interface SubmitResult {
|
|
34
|
+
branch: string;
|
|
35
|
+
number: number;
|
|
36
|
+
url: string;
|
|
37
|
+
action: "created" | "updated" | "unchanged";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STACKED_MARKER_START = "<!-- stacked -->";
|
|
41
|
+
const STACKED_MARKER_END = "<!-- /stacked -->";
|
|
42
|
+
|
|
43
|
+
const generateStackMetadata = (
|
|
44
|
+
branches: readonly string[],
|
|
45
|
+
prMap: Map<string, { number: number; url: string; state: string } | null>,
|
|
46
|
+
currentIdx: number,
|
|
47
|
+
stackName: string,
|
|
48
|
+
): string => {
|
|
49
|
+
const rows = branches.map((branch, i) => {
|
|
50
|
+
const pr = prMap.get(branch) ?? null;
|
|
51
|
+
const isCurrent = i === currentIdx;
|
|
52
|
+
const branchCol = isCurrent ? `**\`${branch}\`**` : `\`${branch}\``;
|
|
53
|
+
const numCol = i + 1;
|
|
54
|
+
const numStr = isCurrent ? `**${numCol}**` : `${numCol}`;
|
|
55
|
+
|
|
56
|
+
let prCol: string;
|
|
57
|
+
if (pr === null) {
|
|
58
|
+
prCol = "—";
|
|
59
|
+
} else if (pr.state === "MERGED") {
|
|
60
|
+
prCol = `[#${pr.number}](${pr.url}) ✅`;
|
|
61
|
+
} else if (isCurrent) {
|
|
62
|
+
prCol = `**#${pr.number} ← you are here**`;
|
|
63
|
+
} else {
|
|
64
|
+
prCol = `[#${pr.number}](${pr.url})`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return `| ${numStr} | ${branchCol} | ${prCol} |`;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
STACKED_MARKER_START,
|
|
72
|
+
`**Stack: \`${stackName}\`** (${currentIdx + 1} of ${branches.length})`,
|
|
73
|
+
"",
|
|
74
|
+
"| # | Branch | PR |",
|
|
75
|
+
"|---|--------|----|",
|
|
76
|
+
...rows,
|
|
77
|
+
STACKED_MARKER_END,
|
|
78
|
+
].join("\n");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const composePRBody = (userBody: string | undefined, metadata: string): string => {
|
|
82
|
+
if (userBody !== undefined) {
|
|
83
|
+
return `${userBody}\n\n---\n\n${metadata}`;
|
|
84
|
+
}
|
|
85
|
+
return metadata;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const updatePRBody = (
|
|
89
|
+
existingBody: string | undefined,
|
|
90
|
+
userBody: string | undefined,
|
|
91
|
+
metadata: string,
|
|
92
|
+
): string => {
|
|
93
|
+
if (userBody !== undefined) {
|
|
94
|
+
return composePRBody(userBody, metadata);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (existingBody !== undefined) {
|
|
98
|
+
const startIdx = existingBody.indexOf(STACKED_MARKER_START);
|
|
99
|
+
if (startIdx !== -1) {
|
|
100
|
+
const prefix = existingBody.substring(0, startIdx).replace(/\n*---\n*$/, "");
|
|
101
|
+
if (prefix.trim().length > 0) {
|
|
102
|
+
return `${prefix.trim()}\n\n---\n\n${metadata}`;
|
|
103
|
+
}
|
|
104
|
+
return metadata;
|
|
105
|
+
}
|
|
106
|
+
return `${existingBody.trim()}\n\n---\n\n${metadata}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return metadata;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
10
113
|
|
|
11
114
|
export const submit = Command.make("submit", {
|
|
12
115
|
draft: draftFlag,
|
|
13
|
-
|
|
116
|
+
noForce: noForceFlag,
|
|
14
117
|
dryRun: dryRunFlag,
|
|
118
|
+
title: titleFlag,
|
|
119
|
+
body: bodyFlag,
|
|
120
|
+
only: onlyFlag,
|
|
121
|
+
json: jsonFlag,
|
|
15
122
|
}).pipe(
|
|
16
123
|
Command.withDescription("Push all stack branches and create/update PRs via gh"),
|
|
17
|
-
Command.
|
|
124
|
+
Command.withExamples([
|
|
125
|
+
{ command: "stacked submit", description: "Push and create/update PRs for all branches" },
|
|
126
|
+
{ command: "stacked submit --draft", description: "Create PRs as drafts" },
|
|
127
|
+
{ command: "stacked submit --only", description: "Submit only the current branch" },
|
|
128
|
+
{
|
|
129
|
+
command: 'stacked submit --title "Add auth" --body "Implements OAuth2"',
|
|
130
|
+
description: "With PR title and body",
|
|
131
|
+
},
|
|
132
|
+
]),
|
|
133
|
+
Command.withHandler(({ draft, noForce, dryRun, title: titleOpt, body: bodyOpt, only, json }) =>
|
|
18
134
|
Effect.gen(function* () {
|
|
19
135
|
const git = yield* GitService;
|
|
20
136
|
const stacks = yield* StackService;
|
|
21
137
|
const gh = yield* GitHubService;
|
|
22
138
|
|
|
139
|
+
const ghInstalled = yield* gh.isGhInstalled();
|
|
140
|
+
if (!ghInstalled) {
|
|
141
|
+
return yield* new StackError({
|
|
142
|
+
code: ErrorCode.GH_NOT_INSTALLED,
|
|
143
|
+
message: "gh CLI is not installed. Install it from https://cli.github.com",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
23
147
|
const result = yield* stacks.currentStack();
|
|
24
148
|
if (result === null) {
|
|
25
|
-
yield*
|
|
26
|
-
|
|
149
|
+
return yield* new StackError({
|
|
150
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
151
|
+
message:
|
|
152
|
+
"Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
|
|
153
|
+
});
|
|
27
154
|
}
|
|
28
155
|
|
|
29
156
|
const trunk = yield* stacks.getTrunk();
|
|
157
|
+
const currentBranch = yield* git.currentBranch();
|
|
30
158
|
const { branches } = result.stack;
|
|
31
159
|
|
|
160
|
+
const rawTitle = Option.isSome(titleOpt) ? titleOpt.value : undefined;
|
|
161
|
+
const rawBody = Option.isSome(bodyOpt) ? bodyOpt.value : undefined;
|
|
162
|
+
|
|
163
|
+
// Parse comma-delimited titles/bodies for per-branch support
|
|
164
|
+
const titles =
|
|
165
|
+
rawTitle !== undefined && rawTitle.includes(",")
|
|
166
|
+
? rawTitle.split(",").map((s) => s.trim())
|
|
167
|
+
: undefined;
|
|
168
|
+
const bodies =
|
|
169
|
+
rawBody !== undefined && rawBody.includes(",")
|
|
170
|
+
? rawBody.split(",").map((s) => s.trim())
|
|
171
|
+
: undefined;
|
|
172
|
+
|
|
173
|
+
if (titles !== undefined && titles.length !== branches.length) {
|
|
174
|
+
return yield* new StackError({
|
|
175
|
+
message: `--title has ${titles.length} values but stack has ${branches.length} branches`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (bodies !== undefined && bodies.length !== branches.length) {
|
|
179
|
+
return yield* new StackError({
|
|
180
|
+
message: `--body has ${bodies.length} values but stack has ${branches.length} branches`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const getTitleForBranch = (branch: string, idx: number): string | undefined => {
|
|
185
|
+
if (titles !== undefined) return titles[idx];
|
|
186
|
+
// Single --title: apply only to current branch
|
|
187
|
+
if (rawTitle !== undefined && branch === currentBranch) return rawTitle;
|
|
188
|
+
return undefined;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const getBodyForBranch = (branch: string, idx: number): string | undefined => {
|
|
192
|
+
if (bodies !== undefined) return bodies[idx];
|
|
193
|
+
// Single --body: apply only to current branch
|
|
194
|
+
if (rawBody !== undefined && branch === currentBranch) return rawBody;
|
|
195
|
+
return undefined;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const results: SubmitResult[] = [];
|
|
199
|
+
const prMap = new Map<
|
|
200
|
+
string,
|
|
201
|
+
{ number: number; url: string; state: string; body?: string | null } | null
|
|
202
|
+
>();
|
|
203
|
+
|
|
32
204
|
for (let i = 0; i < branches.length; i++) {
|
|
33
205
|
const branch = branches[i];
|
|
34
206
|
if (branch === undefined) continue;
|
|
35
207
|
const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
|
|
36
208
|
|
|
209
|
+
// --only: skip branches that aren't current
|
|
210
|
+
if (only && branch !== currentBranch) continue;
|
|
211
|
+
|
|
37
212
|
if (dryRun) {
|
|
38
|
-
yield* Console.
|
|
213
|
+
yield* Console.error(`Would push ${branch} and create/update PR (base: ${base})`);
|
|
39
214
|
continue;
|
|
40
215
|
}
|
|
41
216
|
|
|
42
|
-
yield*
|
|
43
|
-
yield* git.push(branch, { force });
|
|
217
|
+
yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: !noForce }));
|
|
44
218
|
|
|
45
219
|
const existingPR = yield* gh.getPR(branch);
|
|
220
|
+
prMap.set(branch, existingPR);
|
|
46
221
|
|
|
47
222
|
if (existingPR !== null) {
|
|
48
223
|
if (existingPR.base !== base) {
|
|
49
|
-
yield* Console.
|
|
224
|
+
yield* Console.error(`Updating PR #${existingPR.number} base to ${base}`);
|
|
50
225
|
yield* gh.updatePR({ branch, base });
|
|
226
|
+
results.push({
|
|
227
|
+
branch,
|
|
228
|
+
number: existingPR.number,
|
|
229
|
+
url: existingPR.url,
|
|
230
|
+
action: "updated",
|
|
231
|
+
});
|
|
51
232
|
} else {
|
|
52
|
-
yield* Console.
|
|
233
|
+
yield* Console.error(`PR #${existingPR.number} already exists: ${existingPR.url}`);
|
|
234
|
+
results.push({
|
|
235
|
+
branch,
|
|
236
|
+
number: existingPR.number,
|
|
237
|
+
url: existingPR.url,
|
|
238
|
+
action: "unchanged",
|
|
239
|
+
});
|
|
53
240
|
}
|
|
54
241
|
} else {
|
|
242
|
+
const userTitle = getTitleForBranch(branch, i);
|
|
243
|
+
const title =
|
|
244
|
+
userTitle ?? branch.replace(/[-_]/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
|
245
|
+
const metadata = generateStackMetadata(branches, prMap, i, result.name);
|
|
246
|
+
const userBody = getBodyForBranch(branch, i);
|
|
247
|
+
const body = composePRBody(userBody, metadata);
|
|
248
|
+
|
|
55
249
|
const pr = yield* gh.createPR({
|
|
56
250
|
head: branch,
|
|
57
251
|
base,
|
|
58
|
-
title
|
|
252
|
+
title,
|
|
253
|
+
body,
|
|
59
254
|
draft,
|
|
60
255
|
});
|
|
61
|
-
|
|
256
|
+
prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN" });
|
|
257
|
+
yield* success(`Created PR #${pr.number}: ${pr.url}`);
|
|
258
|
+
results.push({
|
|
259
|
+
branch,
|
|
260
|
+
number: pr.number,
|
|
261
|
+
url: pr.url,
|
|
262
|
+
action: "created",
|
|
263
|
+
});
|
|
62
264
|
}
|
|
63
265
|
}
|
|
64
266
|
|
|
65
|
-
|
|
267
|
+
if (dryRun) return;
|
|
268
|
+
|
|
269
|
+
// Update existing PRs with stack metadata
|
|
270
|
+
for (let i = 0; i < branches.length; i++) {
|
|
271
|
+
const branch = branches[i];
|
|
272
|
+
if (branch === undefined) continue;
|
|
273
|
+
if (only && branch !== currentBranch) continue;
|
|
274
|
+
|
|
275
|
+
const entry = results.find((x) => x.branch === branch);
|
|
276
|
+
if (entry === undefined || entry.action === "created") continue;
|
|
277
|
+
|
|
278
|
+
const metadata = generateStackMetadata(branches, prMap, i, result.name);
|
|
279
|
+
const existingPrData = prMap.get(branch) ?? null;
|
|
280
|
+
const existingBody = existingPrData?.body ?? undefined;
|
|
281
|
+
const userBody = getBodyForBranch(branch, i);
|
|
282
|
+
const body = updatePRBody(existingBody, userBody, metadata);
|
|
283
|
+
yield* gh.updatePR({ branch, body });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Print structured output to stdout
|
|
287
|
+
if (json) {
|
|
288
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
289
|
+
yield* Console.log(JSON.stringify({ results }, null, 2));
|
|
290
|
+
} else {
|
|
291
|
+
for (const r of results) {
|
|
292
|
+
yield* Console.log(`${r.branch} #${r.number} ${r.url} ${r.action}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
66
295
|
}),
|
|
67
296
|
),
|
|
68
297
|
);
|
package/src/commands/sync.ts
CHANGED
|
@@ -2,13 +2,43 @@ import { Command, Flag } from "effect/unstable/cli";
|
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
3
|
import { GitService } from "../services/Git.js";
|
|
4
4
|
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { withSpinner, success, warn } from "../ui.js";
|
|
5
7
|
|
|
6
|
-
const trunkFlag = Flag.string("trunk").pipe(
|
|
7
|
-
|
|
8
|
+
const trunkFlag = Flag.string("trunk").pipe(
|
|
9
|
+
Flag.optional,
|
|
10
|
+
Flag.withAlias("t"),
|
|
11
|
+
Flag.withDescription("Override trunk branch for this sync"),
|
|
12
|
+
);
|
|
13
|
+
const fromFlag = Flag.string("from").pipe(
|
|
14
|
+
Flag.optional,
|
|
15
|
+
Flag.withAlias("f"),
|
|
16
|
+
Flag.withDescription("Start rebasing after this branch (exclusive)"),
|
|
17
|
+
);
|
|
18
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
19
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
20
|
+
Flag.withDescription("Show rebase plan without executing"),
|
|
21
|
+
);
|
|
8
22
|
|
|
9
|
-
|
|
23
|
+
interface SyncResult {
|
|
24
|
+
name: string;
|
|
25
|
+
action: "rebased" | "skipped" | "up-to-date";
|
|
26
|
+
base: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const sync = Command.make("sync", {
|
|
30
|
+
trunk: trunkFlag,
|
|
31
|
+
from: fromFlag,
|
|
32
|
+
json: jsonFlag,
|
|
33
|
+
dryRun: dryRunFlag,
|
|
34
|
+
}).pipe(
|
|
10
35
|
Command.withDescription("Fetch and rebase stack on trunk. Use --from to start from a branch."),
|
|
11
|
-
Command.
|
|
36
|
+
Command.withExamples([
|
|
37
|
+
{ command: "stacked sync", description: "Rebase entire stack on trunk" },
|
|
38
|
+
{ command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
|
|
39
|
+
{ command: "stacked sync --dry-run", description: "Preview rebase plan" },
|
|
40
|
+
]),
|
|
41
|
+
Command.withHandler(({ trunk: trunkOpt, from: fromOpt, json, dryRun }) =>
|
|
12
42
|
Effect.gen(function* () {
|
|
13
43
|
const git = yield* GitService;
|
|
14
44
|
const stacks = yield* StackService;
|
|
@@ -16,13 +46,23 @@ export const sync = Command.make("sync", { trunk: trunkFlag, from: fromFlag }).p
|
|
|
16
46
|
const trunk = Option.isSome(trunkOpt) ? trunkOpt.value : yield* stacks.getTrunk();
|
|
17
47
|
const currentBranch = yield* git.currentBranch();
|
|
18
48
|
|
|
19
|
-
|
|
20
|
-
|
|
49
|
+
if (!dryRun) {
|
|
50
|
+
const clean = yield* git.isClean();
|
|
51
|
+
if (!clean) {
|
|
52
|
+
return yield* new StackError({
|
|
53
|
+
code: ErrorCode.DIRTY_WORKTREE,
|
|
54
|
+
message: "Working tree has uncommitted changes. Commit or stash before syncing.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
21
58
|
|
|
22
59
|
const result = yield* stacks.currentStack();
|
|
23
60
|
if (result === null) {
|
|
24
|
-
yield*
|
|
25
|
-
|
|
61
|
+
return yield* new StackError({
|
|
62
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
63
|
+
message:
|
|
64
|
+
"Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
|
|
65
|
+
});
|
|
26
66
|
}
|
|
27
67
|
|
|
28
68
|
const { branches } = result.stack;
|
|
@@ -32,23 +72,91 @@ export const sync = Command.make("sync", { trunk: trunkFlag, from: fromFlag }).p
|
|
|
32
72
|
if (fromBranch !== undefined) {
|
|
33
73
|
const idx = branches.indexOf(fromBranch);
|
|
34
74
|
if (idx === -1) {
|
|
35
|
-
yield*
|
|
36
|
-
|
|
75
|
+
return yield* new StackError({
|
|
76
|
+
code: ErrorCode.BRANCH_NOT_FOUND,
|
|
77
|
+
message: `Branch "${fromBranch}" not found in stack`,
|
|
78
|
+
});
|
|
37
79
|
}
|
|
38
80
|
startIdx = idx + 1;
|
|
81
|
+
if (startIdx >= branches.length) {
|
|
82
|
+
yield* warn(`Nothing to sync — ${fromBranch} is the last branch in the stack`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
39
85
|
}
|
|
40
86
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
87
|
+
const results: SyncResult[] = [];
|
|
88
|
+
|
|
89
|
+
if (dryRun) {
|
|
90
|
+
for (let i = startIdx; i < branches.length; i++) {
|
|
91
|
+
const branch = branches[i];
|
|
92
|
+
if (branch === undefined) continue;
|
|
93
|
+
const base = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
|
|
94
|
+
results.push({ name: branch, action: "skipped", base });
|
|
95
|
+
if (!json) {
|
|
96
|
+
yield* Console.error(`Would rebase ${branch} onto ${base}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (json) {
|
|
101
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
102
|
+
yield* Console.log(JSON.stringify({ branches: results }, null, 2));
|
|
103
|
+
} else {
|
|
104
|
+
yield* Console.error(
|
|
105
|
+
`\n${results.length} branch${results.length === 1 ? "" : "es"} would be rebased`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
48
109
|
}
|
|
49
110
|
|
|
50
|
-
yield* git.
|
|
51
|
-
|
|
111
|
+
yield* withSpinner(`Fetching ${trunk}`, git.fetch());
|
|
112
|
+
|
|
113
|
+
yield* Effect.gen(function* () {
|
|
114
|
+
for (let i = startIdx; i < branches.length; i++) {
|
|
115
|
+
const branch = branches[i];
|
|
116
|
+
if (branch === undefined) continue;
|
|
117
|
+
const newBase = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
|
|
118
|
+
|
|
119
|
+
// Compute old base (merge-base of this branch and its parent) before rebasing
|
|
120
|
+
const oldBase = yield* git
|
|
121
|
+
.mergeBase(branch, newBase)
|
|
122
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase)));
|
|
123
|
+
|
|
124
|
+
yield* git.checkout(branch);
|
|
125
|
+
yield* withSpinner(
|
|
126
|
+
`Rebasing ${branch} onto ${newBase}`,
|
|
127
|
+
git.rebaseOnto(branch, newBase, oldBase),
|
|
128
|
+
).pipe(
|
|
129
|
+
Effect.catchTag("GitError", (e) => {
|
|
130
|
+
const hint =
|
|
131
|
+
i === 0 ? "stacked sync" : `stacked sync --from ${branches[i - 1] ?? trunk}`;
|
|
132
|
+
return Effect.fail(
|
|
133
|
+
new StackError({
|
|
134
|
+
code: ErrorCode.REBASE_CONFLICT,
|
|
135
|
+
message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue\n ${hint}`,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
results.push({ name: branch, action: "rebased", base: newBase });
|
|
141
|
+
}
|
|
142
|
+
}).pipe(
|
|
143
|
+
Effect.ensuring(
|
|
144
|
+
git
|
|
145
|
+
.isRebaseInProgress()
|
|
146
|
+
.pipe(
|
|
147
|
+
Effect.andThen((inProgress) =>
|
|
148
|
+
inProgress ? Effect.void : git.checkout(currentBranch).pipe(Effect.ignore),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (json) {
|
|
155
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
156
|
+
yield* Console.log(JSON.stringify({ branches: results }, null, 2));
|
|
157
|
+
} else {
|
|
158
|
+
yield* success("Stack synced");
|
|
159
|
+
}
|
|
52
160
|
}),
|
|
53
161
|
),
|
|
54
162
|
);
|
package/src/commands/top.ts
CHANGED
|
@@ -1,29 +1,45 @@
|
|
|
1
|
-
import { Command } from "effect/unstable/cli";
|
|
1
|
+
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 { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { success } from "../ui.js";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
|
+
|
|
10
|
+
export const top = Command.make("top", { json: jsonFlag }).pipe(
|
|
7
11
|
Command.withDescription("Checkout top branch of stack"),
|
|
8
|
-
Command.
|
|
12
|
+
Command.withExamples([{ command: "stacked top", description: "Jump to the top of the stack" }]),
|
|
13
|
+
Command.withHandler(({ json }) =>
|
|
9
14
|
Effect.gen(function* () {
|
|
10
15
|
const git = yield* GitService;
|
|
11
16
|
const stacks = yield* StackService;
|
|
12
17
|
|
|
13
18
|
const result = yield* stacks.currentStack();
|
|
14
19
|
if (result === null) {
|
|
15
|
-
yield*
|
|
16
|
-
|
|
20
|
+
return yield* new StackError({
|
|
21
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
22
|
+
message:
|
|
23
|
+
"Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
|
|
24
|
+
});
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
const topBranch = result.stack.branches[result.stack.branches.length - 1];
|
|
20
28
|
if (topBranch === undefined) {
|
|
21
|
-
yield*
|
|
22
|
-
|
|
29
|
+
return yield* new StackError({
|
|
30
|
+
code: ErrorCode.STACK_EMPTY,
|
|
31
|
+
message: "Stack is empty. Run 'stacked create <name>' to add a branch.",
|
|
32
|
+
});
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
yield* git.checkout(topBranch);
|
|
26
|
-
|
|
36
|
+
|
|
37
|
+
if (json) {
|
|
38
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
39
|
+
yield* Console.log(JSON.stringify({ branch: topBranch }, null, 2));
|
|
40
|
+
} else {
|
|
41
|
+
yield* success(`Switched to ${topBranch}`);
|
|
42
|
+
}
|
|
27
43
|
}),
|
|
28
44
|
),
|
|
29
45
|
);
|
package/src/commands/trunk.ts
CHANGED
|
@@ -1,20 +1,51 @@
|
|
|
1
|
-
import { Argument, Command } from "effect/unstable/cli";
|
|
1
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
3
4
|
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { validateBranchName } from "./helpers/validate.js";
|
|
4
7
|
|
|
5
|
-
const nameArg = Argument.string("name").pipe(
|
|
8
|
+
const nameArg = Argument.string("name").pipe(
|
|
9
|
+
Argument.withDescription("Trunk branch name to set"),
|
|
10
|
+
Argument.optional,
|
|
11
|
+
);
|
|
12
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
6
13
|
|
|
7
|
-
export const trunk = Command.make("trunk", { name: nameArg }).pipe(
|
|
14
|
+
export const trunk = Command.make("trunk", { name: nameArg, json: jsonFlag }).pipe(
|
|
8
15
|
Command.withDescription("Get or set the trunk branch"),
|
|
9
|
-
Command.
|
|
16
|
+
Command.withExamples([
|
|
17
|
+
{ command: "stacked trunk", description: "Print current trunk branch" },
|
|
18
|
+
{ command: "stacked trunk develop", description: "Set trunk to develop" },
|
|
19
|
+
{ command: "stacked trunk --json", description: "JSON output" },
|
|
20
|
+
]),
|
|
21
|
+
Command.withHandler(({ name, json }) =>
|
|
10
22
|
Effect.gen(function* () {
|
|
23
|
+
const git = yield* GitService;
|
|
11
24
|
const stacks = yield* StackService;
|
|
12
25
|
if (Option.isSome(name)) {
|
|
26
|
+
yield* validateBranchName(name.value);
|
|
27
|
+
const exists = yield* git.branchExists(name.value);
|
|
28
|
+
if (!exists) {
|
|
29
|
+
return yield* new StackError({
|
|
30
|
+
code: ErrorCode.BRANCH_NOT_FOUND,
|
|
31
|
+
message: `Branch "${name.value}" does not exist`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
13
34
|
yield* stacks.setTrunk(name.value);
|
|
14
|
-
|
|
35
|
+
if (json) {
|
|
36
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
37
|
+
yield* Console.log(JSON.stringify({ trunk: name.value }, null, 2));
|
|
38
|
+
} else {
|
|
39
|
+
yield* Console.error(`Trunk set to ${name.value}`);
|
|
40
|
+
}
|
|
15
41
|
} else {
|
|
16
42
|
const current = yield* stacks.getTrunk();
|
|
17
|
-
|
|
43
|
+
if (json) {
|
|
44
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
45
|
+
yield* Console.log(JSON.stringify({ trunk: current }, null, 2));
|
|
46
|
+
} else {
|
|
47
|
+
yield* Console.log(current);
|
|
48
|
+
}
|
|
18
49
|
}
|
|
19
50
|
}),
|
|
20
51
|
),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { success } from "../ui.js";
|
|
7
|
+
|
|
8
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
|
+
|
|
10
|
+
export const up = Command.make("up", { json: jsonFlag }).pipe(
|
|
11
|
+
Command.withDescription("Move up one branch in the stack"),
|
|
12
|
+
Command.withExamples([{ command: "stacked up", description: "Move to the next branch above" }]),
|
|
13
|
+
Command.withHandler(({ json }) =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const git = yield* GitService;
|
|
16
|
+
const stacks = yield* StackService;
|
|
17
|
+
|
|
18
|
+
const currentBranch = yield* git.currentBranch();
|
|
19
|
+
const result = yield* stacks.currentStack();
|
|
20
|
+
if (result === null) {
|
|
21
|
+
return yield* new StackError({
|
|
22
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
23
|
+
message:
|
|
24
|
+
"Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { branches } = result.stack;
|
|
29
|
+
const idx = branches.indexOf(currentBranch);
|
|
30
|
+
if (idx === -1) {
|
|
31
|
+
return yield* new StackError({
|
|
32
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
33
|
+
message: "Current branch not found in stack",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const next = branches[idx + 1];
|
|
38
|
+
if (next === undefined) {
|
|
39
|
+
return yield* new StackError({
|
|
40
|
+
code: ErrorCode.ALREADY_AT_TOP,
|
|
41
|
+
message: "Already at the top of the stack",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
yield* git.checkout(next);
|
|
46
|
+
|
|
47
|
+
if (json) {
|
|
48
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
49
|
+
yield* Console.log(JSON.stringify({ branch: next, from: currentBranch }, null, 2));
|
|
50
|
+
} else {
|
|
51
|
+
yield* success(`Switched to ${next}`);
|
|
52
|
+
}
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
);
|