@cvr/stacked 0.4.4 → 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/bin/stacked +0 -0
- package/package.json +1 -1
- package/src/commands/clean.ts +6 -4
- package/src/commands/delete.ts +1 -0
- package/src/commands/detect.ts +68 -9
- package/src/commands/doctor.ts +6 -4
- package/src/commands/helpers/pr-metadata.ts +12 -4
- package/src/commands/index.ts +1 -1
- package/src/commands/init.ts +19 -10
- package/src/commands/log.ts +12 -2
- package/src/commands/reorder.ts +2 -0
- package/src/commands/stacks.ts +2 -2
- package/src/commands/submit.ts +33 -6
- package/src/commands/sync.ts +14 -2
- package/src/errors/index.ts +2 -0
- package/src/main.ts +15 -5
- package/src/services/Git.ts +26 -4
- package/src/services/GitEs.ts +67 -12
- package/src/services/Stack.ts +11 -15
package/bin/stacked
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/commands/clean.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { GitService } from "../services/Git.js";
|
|
|
4
4
|
import { GitHubService } from "../services/GitHub.js";
|
|
5
5
|
import { StackService } from "../services/Stack.js";
|
|
6
6
|
import { ErrorCode, StackError } from "../errors/index.js";
|
|
7
|
-
import { success, warn, dim, confirm } from "../ui.js";
|
|
7
|
+
import { success, warn, dim, confirm, info } from "../ui.js";
|
|
8
8
|
|
|
9
9
|
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
10
10
|
Flag.withDescription("Show what would be removed without making changes"),
|
|
@@ -73,7 +73,7 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
|
|
|
73
73
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
74
74
|
yield* Console.log(JSON.stringify({ removed: [], skipped: [] }, null, 2));
|
|
75
75
|
} else {
|
|
76
|
-
yield*
|
|
76
|
+
yield* info("Nothing to clean");
|
|
77
77
|
if (skippedMerged.length > 0) {
|
|
78
78
|
yield* warn(
|
|
79
79
|
`${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
|
|
@@ -102,6 +102,7 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
const removed: string[] = [];
|
|
105
|
+
const failed: string[] = [];
|
|
105
106
|
|
|
106
107
|
for (const { stackName, branch } of toRemove) {
|
|
107
108
|
if (dryRun) {
|
|
@@ -130,9 +131,10 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
|
|
|
130
131
|
);
|
|
131
132
|
if (deleted) {
|
|
132
133
|
yield* stacks.removeBranch(branch);
|
|
133
|
-
yield* stacks.markMergedBranches([branch]);
|
|
134
134
|
removed.push(branch);
|
|
135
135
|
yield* success(`Removed ${branch} from ${stackName}`);
|
|
136
|
+
} else {
|
|
137
|
+
failed.push(branch);
|
|
136
138
|
}
|
|
137
139
|
}
|
|
138
140
|
}
|
|
@@ -140,7 +142,7 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
|
|
|
140
142
|
if (json) {
|
|
141
143
|
const skipped = skippedMerged.map((x) => x.branch);
|
|
142
144
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
143
|
-
yield* Console.log(JSON.stringify({ removed, skipped }, null, 2));
|
|
145
|
+
yield* Console.log(JSON.stringify({ removed, failed, skipped }, null, 2));
|
|
144
146
|
} else if (dryRun) {
|
|
145
147
|
yield* Console.error(
|
|
146
148
|
`\n${toRemove.length} branch${toRemove.length === 1 ? "" : "es"} would be removed`,
|
package/src/commands/delete.ts
CHANGED
package/src/commands/detect.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
37
37
|
const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
|
|
38
38
|
|
|
39
39
|
if (untracked.length === 0) {
|
|
40
|
-
yield*
|
|
40
|
+
yield* info("No untracked branches found");
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -50,8 +50,13 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
50
50
|
const childOf = new Map<string, string>();
|
|
51
51
|
const unclassified: string[] = [];
|
|
52
52
|
|
|
53
|
+
// Include both untracked and already-tracked branches in tip resolution
|
|
54
|
+
// so untracked branches can discover parents that are already in stacks
|
|
55
|
+
const trackedBranches = [...alreadyTracked];
|
|
56
|
+
const allCandidatesForTips = [...untracked, ...trackedBranches];
|
|
57
|
+
|
|
53
58
|
const tipResults = yield* Effect.forEach(
|
|
54
|
-
|
|
59
|
+
allCandidatesForTips,
|
|
55
60
|
(branch) =>
|
|
56
61
|
git.revParse(branch).pipe(
|
|
57
62
|
Effect.map((oid) => [branch, oid] as const),
|
|
@@ -109,7 +114,7 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
109
114
|
);
|
|
110
115
|
|
|
111
116
|
// Build linear chains from trunk
|
|
112
|
-
// Find branches whose parent is trunk (chain roots)
|
|
117
|
+
// Find branches whose parent is trunk or a tracked branch (chain roots)
|
|
113
118
|
const childrenByParent = new Map<string, string[]>();
|
|
114
119
|
for (const branch of untracked) {
|
|
115
120
|
const parent = childOf.get(branch);
|
|
@@ -119,17 +124,48 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
119
124
|
childrenByParent.set(parent, children);
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
// Branches whose parent is already tracked — extend existing stacks
|
|
128
|
+
const adoptions = new Map<string, string[]>(); // trackedParent → [children]
|
|
129
|
+
for (const branch of untracked) {
|
|
130
|
+
const parent = childOf.get(branch);
|
|
131
|
+
if (parent !== undefined && alreadyTracked.has(parent)) {
|
|
132
|
+
const siblings = childrenByParent.get(parent) ?? [];
|
|
133
|
+
if (siblings.length === 1) {
|
|
134
|
+
const existing = adoptions.get(parent) ?? [];
|
|
135
|
+
// Walk the chain from this branch
|
|
136
|
+
const chain = [branch];
|
|
137
|
+
let current = branch;
|
|
138
|
+
while (true) {
|
|
139
|
+
const children = childrenByParent.get(current) ?? [];
|
|
140
|
+
const child = children[0];
|
|
141
|
+
if (children.length === 1 && child !== undefined) {
|
|
142
|
+
chain.push(child);
|
|
143
|
+
current = child;
|
|
144
|
+
} else break;
|
|
145
|
+
}
|
|
146
|
+
adoptions.set(parent, [...existing, ...chain]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
122
151
|
const chains: string[][] = [];
|
|
123
152
|
const roots = childrenByParent.get(trunk) ?? [];
|
|
124
153
|
|
|
154
|
+
// Track branches consumed by adoptions so they're not double-counted
|
|
155
|
+
const adoptedBranches = new Set<string>();
|
|
156
|
+
for (const branches of adoptions.values()) {
|
|
157
|
+
for (const b of branches) adoptedBranches.add(b);
|
|
158
|
+
}
|
|
159
|
+
|
|
125
160
|
for (const root of roots) {
|
|
161
|
+
if (adoptedBranches.has(root)) continue;
|
|
126
162
|
const chain = [root];
|
|
127
163
|
let current = root;
|
|
128
164
|
|
|
129
165
|
while (true) {
|
|
130
166
|
const children = childrenByParent.get(current) ?? [];
|
|
131
167
|
const child = children[0];
|
|
132
|
-
if (children.length === 1 && child !== undefined) {
|
|
168
|
+
if (children.length === 1 && child !== undefined && !adoptedBranches.has(child)) {
|
|
133
169
|
chain.push(child);
|
|
134
170
|
current = child;
|
|
135
171
|
} else {
|
|
@@ -150,16 +186,40 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
150
186
|
|
|
151
187
|
if (json) {
|
|
152
188
|
const stacksData = chains.map((chain) => ({ name: chain[0] ?? "", branches: chain }));
|
|
189
|
+
const adoptionData = [...adoptions.entries()].map(([parent, branches]) => ({
|
|
190
|
+
parent,
|
|
191
|
+
branches,
|
|
192
|
+
}));
|
|
153
193
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
154
|
-
yield* Console.log(
|
|
194
|
+
yield* Console.log(
|
|
195
|
+
JSON.stringify({ stacks: stacksData, adoptions: adoptionData, forks }, null, 2),
|
|
196
|
+
);
|
|
155
197
|
return;
|
|
156
198
|
}
|
|
157
199
|
|
|
158
|
-
if (chains.length === 0) {
|
|
200
|
+
if (chains.length === 0 && adoptions.size === 0) {
|
|
159
201
|
yield* info("No linear branch chains detected");
|
|
160
202
|
return;
|
|
161
203
|
}
|
|
162
204
|
|
|
205
|
+
// Adopt branches into existing stacks
|
|
206
|
+
for (const [parent, branches] of adoptions) {
|
|
207
|
+
const parentStack = yield* stacks.findBranchStack(parent);
|
|
208
|
+
if (parentStack === null) continue;
|
|
209
|
+
for (const branch of branches) {
|
|
210
|
+
if (dryRun) {
|
|
211
|
+
yield* Console.error(
|
|
212
|
+
`Would adopt "${branch}" into stack "${parentStack.name}" after "${parent}"`,
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
yield* stacks.addBranch(parentStack.name, branch, parent);
|
|
216
|
+
yield* success(
|
|
217
|
+
`Adopted "${branch}" into stack "${parentStack.name}" after "${parent}"`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
163
223
|
for (const chain of chains) {
|
|
164
224
|
const name = chain[0];
|
|
165
225
|
if (name === undefined) continue;
|
|
@@ -177,9 +237,8 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
177
237
|
}
|
|
178
238
|
|
|
179
239
|
if (dryRun) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
240
|
+
const total = chains.length + adoptions.size;
|
|
241
|
+
yield* Console.error(`\n${total} action${total === 1 ? "" : "s"} would be performed`);
|
|
183
242
|
}
|
|
184
243
|
|
|
185
244
|
if (forks.length > 0) {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -66,12 +66,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// Check 2: all tracked branches exist in git
|
|
69
|
+
const allGitBranches = yield* git
|
|
70
|
+
.listBranches()
|
|
71
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed([] as string[])));
|
|
72
|
+
const gitBranchSet = new Set(allGitBranches);
|
|
73
|
+
|
|
69
74
|
for (const { name: stackName, stack } of stackEntries) {
|
|
70
75
|
for (const branch of stack.branches) {
|
|
71
|
-
|
|
72
|
-
.branchExists(branch)
|
|
73
|
-
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
74
|
-
if (!exists) {
|
|
76
|
+
if (!gitBranchSet.has(branch)) {
|
|
75
77
|
if (fix) {
|
|
76
78
|
yield* stacks.removeBranch(branch);
|
|
77
79
|
findings.push({
|
|
@@ -99,17 +99,21 @@ export const refreshStackedPRBodies = ({
|
|
|
99
99
|
getUserBody?: (branch: string, idx: number) => string | undefined;
|
|
100
100
|
}) =>
|
|
101
101
|
Effect.gen(function* () {
|
|
102
|
-
const prEntries = yield* Effect.
|
|
103
|
-
branches
|
|
102
|
+
const prEntries = yield* Effect.forEach(
|
|
103
|
+
branches,
|
|
104
|
+
(branch) => {
|
|
104
105
|
const existing = initialPrMap?.get(branch);
|
|
105
106
|
if (existing !== undefined) {
|
|
106
107
|
return Effect.succeed([branch, existing] as const);
|
|
107
108
|
}
|
|
108
109
|
return gh.getPR(branch).pipe(Effect.map((pr) => [branch, pr] as const));
|
|
109
|
-
}
|
|
110
|
+
},
|
|
111
|
+
{ concurrency: 5 },
|
|
110
112
|
);
|
|
111
113
|
const prMap = new Map(prEntries);
|
|
112
114
|
|
|
115
|
+
// Collect all updates, then apply in parallel
|
|
116
|
+
const updates: Array<{ branch: string; body: string }> = [];
|
|
113
117
|
for (let i = 0; i < branches.length; i++) {
|
|
114
118
|
const branch = branches[i];
|
|
115
119
|
if (branch === undefined) continue;
|
|
@@ -124,8 +128,12 @@ export const refreshStackedPRBodies = ({
|
|
|
124
128
|
getUserBody?.(branch, i),
|
|
125
129
|
metadata,
|
|
126
130
|
);
|
|
127
|
-
|
|
131
|
+
updates.push({ branch, body });
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
yield* Effect.forEach(updates, ({ branch, body }) => gh.updatePR({ branch, body }), {
|
|
135
|
+
concurrency: 5,
|
|
136
|
+
});
|
|
137
|
+
|
|
130
138
|
return prMap;
|
|
131
139
|
});
|
package/src/commands/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { amend } from "./amend.js";
|
|
|
25
25
|
|
|
26
26
|
const root = Command.make("stacked").pipe(
|
|
27
27
|
Command.withDescription(
|
|
28
|
-
"Branch-based stacked PR manager\n\nGlobal flags:\n --verbose
|
|
28
|
+
"Branch-based stacked PR manager\n\nGlobal flags:\n --verbose, -v Show detailed output\n --quiet, -q Suppress non-essential output\n --no-color Disable color output\n --yes, -y Skip confirmation prompts",
|
|
29
29
|
),
|
|
30
30
|
Command.withExamples([
|
|
31
31
|
{ command: "stacked create feat-auth", description: "Create a new branch in the stack" },
|
package/src/commands/init.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Command } from "effect/unstable/cli";
|
|
1
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
2
2
|
import { Config, Console, Effect } from "effect";
|
|
3
3
|
import { StackError } from "../errors/index.js";
|
|
4
4
|
import { mkdirSync, writeFileSync } from "fs";
|
|
@@ -7,10 +7,12 @@ import { homedir } from "os";
|
|
|
7
7
|
|
|
8
8
|
const skillContent = typeof __SKILL_CONTENT__ !== "undefined" ? __SKILL_CONTENT__ : null;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
11
|
+
|
|
12
|
+
export const init = Command.make("init", { json: jsonFlag }).pipe(
|
|
11
13
|
Command.withDescription("Install the stacked Claude skill to ~/.claude/skills"),
|
|
12
14
|
Command.withExamples([{ command: "stacked init", description: "Install the Claude skill" }]),
|
|
13
|
-
Command.withHandler(() =>
|
|
15
|
+
Command.withHandler(({ json }) =>
|
|
14
16
|
Effect.gen(function* () {
|
|
15
17
|
if (skillContent === null) {
|
|
16
18
|
return yield* new StackError({
|
|
@@ -24,7 +26,9 @@ export const init = Command.make("init").pipe(
|
|
|
24
26
|
const targetDir = join(skillsDir, "stacked");
|
|
25
27
|
const targetPath = join(targetDir, "SKILL.md");
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
if (!json) {
|
|
30
|
+
yield* Console.error(`Writing skill to ${targetPath}...`);
|
|
31
|
+
}
|
|
28
32
|
yield* Effect.try({
|
|
29
33
|
try: () => {
|
|
30
34
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -33,12 +37,17 @@ export const init = Command.make("init").pipe(
|
|
|
33
37
|
catch: (e) => new StackError({ message: `Failed to write skill: ${e}` }),
|
|
34
38
|
});
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
if (json) {
|
|
41
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
42
|
+
yield* Console.log(JSON.stringify({ path: targetPath }, null, 2));
|
|
43
|
+
} else {
|
|
44
|
+
yield* Console.error(`Installed stacked skill to ${targetPath}`);
|
|
45
|
+
yield* Console.error("\nNext steps:");
|
|
46
|
+
yield* Console.error(" stacked create <name> # start your first stack");
|
|
47
|
+
yield* Console.error(
|
|
48
|
+
" stacked trunk <name> # only if auto-detection picks the wrong trunk",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
42
51
|
}),
|
|
43
52
|
),
|
|
44
53
|
);
|
package/src/commands/log.ts
CHANGED
|
@@ -28,13 +28,23 @@ export const log = Command.make("log", { json: jsonFlag }).pipe(
|
|
|
28
28
|
|
|
29
29
|
const trunk = yield* stacks.getTrunk();
|
|
30
30
|
const { branches } = result.stack;
|
|
31
|
+
const data = yield* stacks.load();
|
|
32
|
+
const mergedSet = new Set(data.mergedBranches);
|
|
33
|
+
|
|
34
|
+
const effectiveBase = (i: number): string => {
|
|
35
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
36
|
+
const candidate = branches[j];
|
|
37
|
+
if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
|
|
38
|
+
}
|
|
39
|
+
return trunk;
|
|
40
|
+
};
|
|
31
41
|
|
|
32
42
|
if (json) {
|
|
33
43
|
const entries = [];
|
|
34
44
|
for (let i = 0; i < branches.length; i++) {
|
|
35
45
|
const branch = branches[i];
|
|
36
46
|
if (branch === undefined) continue;
|
|
37
|
-
const base =
|
|
47
|
+
const base = effectiveBase(i);
|
|
38
48
|
const commits = yield* git
|
|
39
49
|
.log(`${base}..${branch}`, { oneline: true })
|
|
40
50
|
.pipe(Effect.catchTag("GitError", () => Effect.succeed("")));
|
|
@@ -48,7 +58,7 @@ export const log = Command.make("log", { json: jsonFlag }).pipe(
|
|
|
48
58
|
for (let i = 0; i < branches.length; i++) {
|
|
49
59
|
const branch = branches[i];
|
|
50
60
|
if (branch === undefined) continue;
|
|
51
|
-
const base =
|
|
61
|
+
const base = effectiveBase(i);
|
|
52
62
|
yield* Console.log(`\n── ${branch} ──`);
|
|
53
63
|
const rangeLog = yield* git
|
|
54
64
|
.log(`${base}..${branch}`, { oneline: true })
|
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
|
}
|
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
|
|
|
@@ -24,7 +24,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
|
|
|
24
24
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
25
25
|
yield* Console.log(JSON.stringify({ stacks: [] }));
|
|
26
26
|
} else {
|
|
27
|
-
yield*
|
|
27
|
+
yield* info("No stacks");
|
|
28
28
|
}
|
|
29
29
|
return;
|
|
30
30
|
}
|
package/src/commands/submit.ts
CHANGED
|
@@ -90,6 +90,17 @@ export const submit = Command.make("submit", {
|
|
|
90
90
|
const trunk = yield* stacks.getTrunk();
|
|
91
91
|
const currentBranch = yield* git.currentBranch();
|
|
92
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
|
+
};
|
|
93
104
|
|
|
94
105
|
const rawTitle = Option.isSome(titleOpt) ? titleOpt.value : undefined;
|
|
95
106
|
const rawBody = Option.isSome(bodyOpt) ? bodyOpt.value : undefined;
|
|
@@ -106,11 +117,13 @@ export const submit = Command.make("submit", {
|
|
|
106
117
|
|
|
107
118
|
if (titles !== undefined && titles.length !== branches.length) {
|
|
108
119
|
return yield* new StackError({
|
|
120
|
+
code: ErrorCode.USAGE_ERROR,
|
|
109
121
|
message: `--title has ${titles.length} values but stack has ${branches.length} branches`,
|
|
110
122
|
});
|
|
111
123
|
}
|
|
112
124
|
if (bodies !== undefined && bodies.length !== branches.length) {
|
|
113
125
|
return yield* new StackError({
|
|
126
|
+
code: ErrorCode.USAGE_ERROR,
|
|
114
127
|
message: `--body has ${bodies.length} values but stack has ${branches.length} branches`,
|
|
115
128
|
});
|
|
116
129
|
}
|
|
@@ -132,19 +145,34 @@ export const submit = Command.make("submit", {
|
|
|
132
145
|
const results: SubmitResult[] = [];
|
|
133
146
|
const prMap = new Map<
|
|
134
147
|
string,
|
|
135
|
-
{ number: number; url: string; state: string; body?: string | null } | null
|
|
148
|
+
{ number: number; url: string; state: string; base: string; body?: string | null } | null
|
|
136
149
|
>();
|
|
137
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
|
+
|
|
138
166
|
for (let i = 0; i < branches.length; i++) {
|
|
139
167
|
const branch = branches[i];
|
|
140
168
|
if (branch === undefined) continue;
|
|
141
|
-
const base =
|
|
169
|
+
const base = effectiveBase(i);
|
|
142
170
|
|
|
143
171
|
// --only: skip branches that aren't current
|
|
144
172
|
if (only && branch !== currentBranch) continue;
|
|
145
173
|
|
|
146
174
|
if (dryRun) {
|
|
147
|
-
const existingPR =
|
|
175
|
+
const existingPR = prMap.get(branch) ?? null;
|
|
148
176
|
const action =
|
|
149
177
|
existingPR === null
|
|
150
178
|
? "would-create"
|
|
@@ -166,8 +194,7 @@ export const submit = Command.make("submit", {
|
|
|
166
194
|
|
|
167
195
|
yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: !noForce }));
|
|
168
196
|
|
|
169
|
-
const existingPR =
|
|
170
|
-
prMap.set(branch, existingPR);
|
|
197
|
+
const existingPR = prMap.get(branch) ?? null;
|
|
171
198
|
|
|
172
199
|
if (existingPR !== null) {
|
|
173
200
|
if (existingPR.base !== base) {
|
|
@@ -205,7 +232,7 @@ export const submit = Command.make("submit", {
|
|
|
205
232
|
body,
|
|
206
233
|
draft,
|
|
207
234
|
});
|
|
208
|
-
prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN" });
|
|
235
|
+
prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN", base });
|
|
209
236
|
yield* success(`Created PR #${pr.number}: ${pr.url}`);
|
|
210
237
|
results.push({
|
|
211
238
|
branch,
|
package/src/commands/sync.ts
CHANGED
|
@@ -72,6 +72,18 @@ export const sync = Command.make("sync", {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
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
|
+
|
|
75
87
|
const fromBranch = Option.isSome(fromOpt) ? fromOpt.value : undefined;
|
|
76
88
|
|
|
77
89
|
let startIdx = 0;
|
|
@@ -101,7 +113,7 @@ export const sync = Command.make("sync", {
|
|
|
101
113
|
for (let i = startIdx; i < branches.length; i++) {
|
|
102
114
|
const branch = branches[i];
|
|
103
115
|
if (branch === undefined) continue;
|
|
104
|
-
const base =
|
|
116
|
+
const base = effectiveBase(i, originTrunk);
|
|
105
117
|
results.push({ name: branch, action: "skipped", base });
|
|
106
118
|
if (!json) {
|
|
107
119
|
yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
|
|
@@ -137,7 +149,7 @@ export const sync = Command.make("sync", {
|
|
|
137
149
|
for (let i = startIdx; i < branches.length; i++) {
|
|
138
150
|
const branch = branches[i];
|
|
139
151
|
if (branch === undefined) continue;
|
|
140
|
-
const newBase =
|
|
152
|
+
const newBase = effectiveBase(i, originTrunk);
|
|
141
153
|
|
|
142
154
|
// Compute old base (merge-base of this branch and its parent) before rebasing
|
|
143
155
|
const oldBase = yield* git
|
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
|
@@ -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");
|
|
@@ -53,16 +53,26 @@ const usageCodes = new Set([
|
|
|
53
53
|
"ALREADY_AT_BOTTOM",
|
|
54
54
|
"TRUNK_ERROR",
|
|
55
55
|
"STACK_EXISTS",
|
|
56
|
+
"USAGE_ERROR",
|
|
57
|
+
"HAS_CHILDREN",
|
|
56
58
|
]);
|
|
57
59
|
|
|
58
|
-
const
|
|
59
|
-
|
|
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(
|
|
60
69
|
Effect.andThen(
|
|
61
70
|
Effect.sync(() => {
|
|
62
|
-
process.exitCode =
|
|
71
|
+
process.exitCode = exitCode;
|
|
63
72
|
}),
|
|
64
73
|
),
|
|
65
74
|
);
|
|
75
|
+
};
|
|
66
76
|
|
|
67
77
|
// @effect-diagnostics-next-line effect/strictEffectProvide:off
|
|
68
78
|
BunRuntime.runMain(
|
package/src/services/Git.ts
CHANGED
|
@@ -121,7 +121,10 @@ export class GitService extends ServiceMap.Service<
|
|
|
121
121
|
|
|
122
122
|
remoteDefaultBranch: (remote = "origin") =>
|
|
123
123
|
run(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`]).pipe(
|
|
124
|
-
Effect.map((ref) =>
|
|
124
|
+
Effect.map((ref) => {
|
|
125
|
+
const prefix = `${remote}/`;
|
|
126
|
+
return Option.some(ref.startsWith(prefix) ? ref.slice(prefix.length) : ref);
|
|
127
|
+
}),
|
|
125
128
|
Effect.catchTag("GitError", () => Effect.succeed(Option.none())),
|
|
126
129
|
),
|
|
127
130
|
|
|
@@ -192,9 +195,28 @@ export class GitService extends ServiceMap.Service<
|
|
|
192
195
|
),
|
|
193
196
|
|
|
194
197
|
commitAmend: (options) => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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);
|
|
198
220
|
},
|
|
199
221
|
|
|
200
222
|
fetch: (remote) => run(["fetch", remote ?? "origin"]).pipe(Effect.asVoid),
|
package/src/services/GitEs.ts
CHANGED
|
@@ -111,7 +111,8 @@ export const GitEsLayer = Layer.effect(
|
|
|
111
111
|
const ref = repo.findReference(`refs/remotes/${remote}/HEAD`);
|
|
112
112
|
if (ref === null) return Option.none();
|
|
113
113
|
const target = ref.resolve().shorthand();
|
|
114
|
-
|
|
114
|
+
const prefix = `${remote}/`;
|
|
115
|
+
return Option.some(target.startsWith(prefix) ? target.slice(prefix.length) : target);
|
|
115
116
|
},
|
|
116
117
|
catch: (error) => makeGitError(`es-git.remoteDefaultBranch ${remote}`, error),
|
|
117
118
|
}).pipe(Effect.catchTag("GitError", () => Effect.succeed(Option.none()))),
|
|
@@ -127,13 +128,32 @@ export const GitEsLayer = Layer.effect(
|
|
|
127
128
|
catch: (error) => makeGitError(`es-git.createBranch ${name}`, error),
|
|
128
129
|
}).pipe(Effect.asVoid),
|
|
129
130
|
|
|
130
|
-
deleteBranch: (name) =>
|
|
131
|
-
|
|
131
|
+
deleteBranch: (name, force) => {
|
|
132
|
+
// When force is false/undefined, shell out to `git branch -d` so git
|
|
133
|
+
// enforces "must be fully merged" check. es-git has no equivalent.
|
|
134
|
+
if (force !== true) {
|
|
135
|
+
return Effect.tryPromise({
|
|
136
|
+
try: async () => {
|
|
137
|
+
const proc = Bun.spawn(["git", "branch", "-d", "--", name], {
|
|
138
|
+
stdout: "pipe",
|
|
139
|
+
stderr: "pipe",
|
|
140
|
+
});
|
|
141
|
+
const exitCode = await proc.exited;
|
|
142
|
+
if (exitCode !== 0) {
|
|
143
|
+
const stderr = await new Response(proc.stderr).text();
|
|
144
|
+
throw new Error(stderr.trim() || `git branch -d failed with exit code ${exitCode}`);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
|
|
148
|
+
}).pipe(Effect.asVoid);
|
|
149
|
+
}
|
|
150
|
+
return Effect.try({
|
|
132
151
|
try: () => {
|
|
133
152
|
repo.getBranch(name, "Local").delete();
|
|
134
153
|
},
|
|
135
154
|
catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
|
|
136
|
-
}).pipe(Effect.asVoid)
|
|
155
|
+
}).pipe(Effect.asVoid);
|
|
156
|
+
},
|
|
137
157
|
|
|
138
158
|
checkout: (name) =>
|
|
139
159
|
Effect.try({
|
|
@@ -177,17 +197,34 @@ export const GitEsLayer = Layer.effect(
|
|
|
177
197
|
catch: (error) => makeGitError("es-git.rebaseAbort", error),
|
|
178
198
|
}).pipe(Effect.asVoid),
|
|
179
199
|
|
|
180
|
-
push: (branch, options) =>
|
|
181
|
-
|
|
200
|
+
push: (branch, options) => {
|
|
201
|
+
// es-git's protocol has no --force-with-lease equivalent; shell out to git CLI
|
|
202
|
+
if (options?.force === true) {
|
|
203
|
+
return Effect.tryPromise({
|
|
204
|
+
try: async () => {
|
|
205
|
+
const proc = Bun.spawn(
|
|
206
|
+
["git", "push", "--force-with-lease", "-u", "origin", branch],
|
|
207
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
208
|
+
);
|
|
209
|
+
const exitCode = await proc.exited;
|
|
210
|
+
if (exitCode !== 0) {
|
|
211
|
+
const stderr = await new Response(proc.stderr).text();
|
|
212
|
+
throw new Error(stderr.trim() || `git push failed with exit code ${exitCode}`);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
catch: (error) => makeGitError(`es-git.push ${branch}`, error),
|
|
216
|
+
}).pipe(Effect.asVoid);
|
|
217
|
+
}
|
|
218
|
+
return Effect.tryPromise({
|
|
182
219
|
try: async () => {
|
|
183
220
|
const remote = repo.getRemote("origin");
|
|
184
|
-
|
|
185
|
-
await remote.push([`${forcePrefix}refs/heads/${branch}:refs/heads/${branch}`]);
|
|
221
|
+
await remote.push([`refs/heads/${branch}:refs/heads/${branch}`]);
|
|
186
222
|
const localBranch = repo.findBranch(branch, "Local");
|
|
187
223
|
localBranch?.setUpstream(`origin/${branch}`);
|
|
188
224
|
},
|
|
189
225
|
catch: (error) => makeGitError(`es-git.push ${branch}`, error),
|
|
190
|
-
}).pipe(Effect.asVoid)
|
|
226
|
+
}).pipe(Effect.asVoid);
|
|
227
|
+
},
|
|
191
228
|
|
|
192
229
|
log: (branch, options) =>
|
|
193
230
|
Effect.try({
|
|
@@ -274,8 +311,25 @@ export const GitEsLayer = Layer.effect(
|
|
|
274
311
|
catch: (error) => makeGitError("es-git.isRebaseInProgress", error),
|
|
275
312
|
}).pipe(Effect.catchTag("GitError", () => Effect.succeed(false))),
|
|
276
313
|
|
|
277
|
-
commitAmend: () =>
|
|
278
|
-
|
|
314
|
+
commitAmend: (options) => {
|
|
315
|
+
// --edit requires an interactive editor; shell out to git CLI
|
|
316
|
+
if (options?.edit === true) {
|
|
317
|
+
return Effect.tryPromise({
|
|
318
|
+
try: async () => {
|
|
319
|
+
const proc = Bun.spawn(["git", "commit", "--amend"], {
|
|
320
|
+
stdin: "inherit",
|
|
321
|
+
stdout: "inherit",
|
|
322
|
+
stderr: "inherit",
|
|
323
|
+
});
|
|
324
|
+
const exitCode = await proc.exited;
|
|
325
|
+
if (exitCode !== 0) {
|
|
326
|
+
throw new Error(`git commit --amend failed with exit code ${exitCode}`);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
catch: (error) => makeGitError("es-git.commitAmend", error),
|
|
330
|
+
}).pipe(Effect.asVoid);
|
|
331
|
+
}
|
|
332
|
+
return Effect.try({
|
|
279
333
|
try: () => {
|
|
280
334
|
const headOid = repo.head().target();
|
|
281
335
|
if (headOid === null) {
|
|
@@ -287,7 +341,8 @@ export const GitEsLayer = Layer.effect(
|
|
|
287
341
|
});
|
|
288
342
|
},
|
|
289
343
|
catch: (error) => makeGitError("es-git.commitAmend", error),
|
|
290
|
-
}).pipe(Effect.asVoid)
|
|
344
|
+
}).pipe(Effect.asVoid);
|
|
345
|
+
},
|
|
291
346
|
|
|
292
347
|
fetch: (remote = "origin") =>
|
|
293
348
|
Effect.tryPromise({
|
package/src/services/Stack.ts
CHANGED
|
@@ -252,10 +252,8 @@ const rewriteStackBranches = (
|
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return data;
|
|
258
|
-
}
|
|
255
|
+
// branches.length > 0 guaranteed by the early return above
|
|
256
|
+
const root = branches[0] as string;
|
|
259
257
|
|
|
260
258
|
return {
|
|
261
259
|
...data,
|
|
@@ -636,21 +634,19 @@ export class StackService extends ServiceMap.Service<
|
|
|
636
634
|
readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
|
|
637
635
|
}
|
|
638
636
|
>()("@cvr/stacked/services/Stack/StackService") {
|
|
639
|
-
static layer: Layer.Layer<StackService,
|
|
637
|
+
static layer: Layer.Layer<StackService, StackError, GitService> = Layer.effect(
|
|
640
638
|
StackService,
|
|
641
639
|
Effect.gen(function* () {
|
|
642
640
|
const git = yield* GitService;
|
|
643
641
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return `${gitDir}/stacked.json`;
|
|
653
|
-
});
|
|
642
|
+
// Resolve git dir once at construction time, then capture in closure
|
|
643
|
+
const gitDir = yield* git
|
|
644
|
+
.revParse("--absolute-git-dir")
|
|
645
|
+
.pipe(
|
|
646
|
+
Effect.mapError((e) => new StackError({ message: `Not a git repository: ${e.message}` })),
|
|
647
|
+
);
|
|
648
|
+
const resolvedStackFilePath = `${gitDir}/stacked.json`;
|
|
649
|
+
const stackFilePath = () => Effect.succeed(resolvedStackFilePath);
|
|
654
650
|
|
|
655
651
|
const StackFileJson = Schema.fromJsonString(
|
|
656
652
|
Schema.Union([StackFileV1Schema, StackFileV2Schema]),
|