@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/create.ts
CHANGED
|
@@ -80,20 +80,24 @@ export const create = Command.make("create", {
|
|
|
80
80
|
|
|
81
81
|
const existing = yield* stacks.findBranchStack(baseBranch);
|
|
82
82
|
let stackName = existing?.name ?? null;
|
|
83
|
+
let shouldAddBranch = true;
|
|
83
84
|
|
|
84
85
|
yield* git.createBranch(name, baseBranch);
|
|
85
86
|
|
|
86
87
|
if (stackName === null) {
|
|
87
88
|
if (baseBranch === trunk) {
|
|
88
89
|
stackName = name;
|
|
89
|
-
|
|
90
|
+
shouldAddBranch = false;
|
|
91
|
+
yield* stacks.createStack(name, [name]);
|
|
90
92
|
} else {
|
|
91
93
|
stackName = baseBranch;
|
|
92
94
|
yield* stacks.createStack(baseBranch, [baseBranch]);
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
if (shouldAddBranch) {
|
|
99
|
+
yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
|
|
100
|
+
}
|
|
97
101
|
|
|
98
102
|
if (json) {
|
|
99
103
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
package/src/commands/delete.ts
CHANGED
|
@@ -50,6 +50,7 @@ export const deleteCmd = Command.make("delete", {
|
|
|
50
50
|
|
|
51
51
|
if (idx < stack.branches.length - 1 && !force) {
|
|
52
52
|
return yield* new StackError({
|
|
53
|
+
code: ErrorCode.HAS_CHILDREN,
|
|
53
54
|
message: `Branch "${name}" has children. Use --force to delete anyway.`,
|
|
54
55
|
});
|
|
55
56
|
}
|
|
@@ -101,7 +102,7 @@ export const deleteCmd = Command.make("delete", {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
yield* git.deleteBranch(name, force);
|
|
104
|
-
yield* stacks.removeBranch(
|
|
105
|
+
yield* stacks.removeBranch(name);
|
|
105
106
|
|
|
106
107
|
if (willDeleteRemote) {
|
|
107
108
|
yield* git.deleteRemoteBranch(name).pipe(Effect.catchTag("GitError", () => Effect.void));
|
package/src/commands/detect.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { StackService } from "../services/Stack.js";
|
|
|
5
5
|
import { success, warn, info } from "../ui.js";
|
|
6
6
|
import { detectLimitConfig, limitUntrackedBranches } from "./helpers/detect.js";
|
|
7
7
|
|
|
8
|
+
const DETECT_COMMIT_LIMIT = 2048;
|
|
9
|
+
|
|
8
10
|
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
9
11
|
Flag.withDescription("Show what would be detected without making changes"),
|
|
10
12
|
);
|
|
@@ -26,13 +28,16 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
26
28
|
const candidates = allBranches.filter((b) => b !== trunk);
|
|
27
29
|
|
|
28
30
|
const data = yield* stacks.load();
|
|
29
|
-
const alreadyTracked = new Set(Object.
|
|
30
|
-
const
|
|
31
|
+
const alreadyTracked = new Set(Object.keys(data.branches));
|
|
32
|
+
const mergedBranches = new Set(data.mergedBranches);
|
|
33
|
+
const untrackedAll = candidates.filter(
|
|
34
|
+
(b) => !alreadyTracked.has(b) && !mergedBranches.has(b),
|
|
35
|
+
);
|
|
31
36
|
const detectLimit = yield* detectLimitConfig;
|
|
32
37
|
const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
|
|
33
38
|
|
|
34
39
|
if (untracked.length === 0) {
|
|
35
|
-
yield*
|
|
40
|
+
yield* info("No untracked branches found");
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -42,48 +47,74 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
42
47
|
);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
// Build parent map: for each branch, find its direct parent among other branches
|
|
46
|
-
// A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
|
|
47
50
|
const childOf = new Map<string, string>();
|
|
51
|
+
const unclassified: string[] = [];
|
|
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
|
+
|
|
58
|
+
const tipResults = yield* Effect.forEach(
|
|
59
|
+
allCandidatesForTips,
|
|
60
|
+
(branch) =>
|
|
61
|
+
git.revParse(branch).pipe(
|
|
62
|
+
Effect.map((oid) => [branch, oid] as const),
|
|
63
|
+
Effect.catchTag("GitError", () => Effect.succeed(null)),
|
|
64
|
+
),
|
|
65
|
+
{ concurrency: 5 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const tipOwners = new Map<string, string[]>();
|
|
69
|
+
for (const result of tipResults) {
|
|
70
|
+
if (result === null) continue;
|
|
71
|
+
const [branch, oid] = result;
|
|
72
|
+
const owners = tipOwners.get(oid) ?? [];
|
|
73
|
+
owners.push(branch);
|
|
74
|
+
tipOwners.set(oid, owners);
|
|
75
|
+
}
|
|
48
76
|
|
|
49
77
|
yield* Effect.forEach(
|
|
50
78
|
untracked,
|
|
51
79
|
(branch) =>
|
|
52
80
|
Effect.gen(function* () {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
potentialAncestors,
|
|
57
|
-
(other) =>
|
|
58
|
-
git.isAncestor(other, branch).pipe(
|
|
59
|
-
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
60
|
-
Effect.map((is) => [other, is] as const),
|
|
61
|
-
),
|
|
62
|
-
{ concurrency: 5 },
|
|
63
|
-
);
|
|
81
|
+
const commits = yield* git
|
|
82
|
+
.firstParentUniqueCommits(branch, trunk, { limit: DETECT_COMMIT_LIMIT })
|
|
83
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed([])));
|
|
64
84
|
|
|
65
|
-
|
|
85
|
+
if (commits.length === 0) {
|
|
86
|
+
unclassified.push(branch);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
66
89
|
|
|
67
|
-
|
|
90
|
+
let parent: string | null = null;
|
|
91
|
+
let ambiguous = false;
|
|
68
92
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
for (const oid of commits) {
|
|
94
|
+
const owners = (tipOwners.get(oid) ?? []).filter((owner) => owner !== branch);
|
|
95
|
+
if (owners.length > 1) {
|
|
96
|
+
ambiguous = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const [owner] = owners;
|
|
100
|
+
if (owner !== undefined) {
|
|
101
|
+
parent = owner;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
78
104
|
}
|
|
79
105
|
|
|
80
|
-
|
|
106
|
+
if (ambiguous || (commits.length >= DETECT_COMMIT_LIMIT && parent === null)) {
|
|
107
|
+
unclassified.push(branch);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
childOf.set(branch, parent ?? trunk);
|
|
81
112
|
}),
|
|
82
113
|
{ concurrency: 5 },
|
|
83
114
|
);
|
|
84
115
|
|
|
85
116
|
// Build linear chains from trunk
|
|
86
|
-
// Find branches whose parent is trunk (chain roots)
|
|
117
|
+
// Find branches whose parent is trunk or a tracked branch (chain roots)
|
|
87
118
|
const childrenByParent = new Map<string, string[]>();
|
|
88
119
|
for (const branch of untracked) {
|
|
89
120
|
const parent = childOf.get(branch);
|
|
@@ -93,17 +124,48 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
93
124
|
childrenByParent.set(parent, children);
|
|
94
125
|
}
|
|
95
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
|
+
|
|
96
151
|
const chains: string[][] = [];
|
|
97
152
|
const roots = childrenByParent.get(trunk) ?? [];
|
|
98
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
|
+
|
|
99
160
|
for (const root of roots) {
|
|
161
|
+
if (adoptedBranches.has(root)) continue;
|
|
100
162
|
const chain = [root];
|
|
101
163
|
let current = root;
|
|
102
164
|
|
|
103
165
|
while (true) {
|
|
104
166
|
const children = childrenByParent.get(current) ?? [];
|
|
105
167
|
const child = children[0];
|
|
106
|
-
if (children.length === 1 && child !== undefined) {
|
|
168
|
+
if (children.length === 1 && child !== undefined && !adoptedBranches.has(child)) {
|
|
107
169
|
chain.push(child);
|
|
108
170
|
current = child;
|
|
109
171
|
} else {
|
|
@@ -124,21 +186,45 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
124
186
|
|
|
125
187
|
if (json) {
|
|
126
188
|
const stacksData = chains.map((chain) => ({ name: chain[0] ?? "", branches: chain }));
|
|
189
|
+
const adoptionData = [...adoptions.entries()].map(([parent, branches]) => ({
|
|
190
|
+
parent,
|
|
191
|
+
branches,
|
|
192
|
+
}));
|
|
127
193
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
128
|
-
yield* Console.log(
|
|
194
|
+
yield* Console.log(
|
|
195
|
+
JSON.stringify({ stacks: stacksData, adoptions: adoptionData, forks }, null, 2),
|
|
196
|
+
);
|
|
129
197
|
return;
|
|
130
198
|
}
|
|
131
199
|
|
|
132
|
-
if (chains.length === 0) {
|
|
200
|
+
if (chains.length === 0 && adoptions.size === 0) {
|
|
133
201
|
yield* info("No linear branch chains detected");
|
|
134
202
|
return;
|
|
135
203
|
}
|
|
136
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
|
+
|
|
137
223
|
for (const chain of chains) {
|
|
138
224
|
const name = chain[0];
|
|
139
225
|
if (name === undefined) continue;
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
226
|
+
const existing = yield* stacks.getStack(name);
|
|
227
|
+
if (existing !== null) {
|
|
142
228
|
yield* warn(`Stack "${name}" already exists, skipping: ${chain.join(" → ")}`);
|
|
143
229
|
continue;
|
|
144
230
|
}
|
|
@@ -151,9 +237,8 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
151
237
|
}
|
|
152
238
|
|
|
153
239
|
if (dryRun) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
);
|
|
240
|
+
const total = chains.length + adoptions.size;
|
|
241
|
+
yield* Console.error(`\n${total} action${total === 1 ? "" : "s"} would be performed`);
|
|
157
242
|
}
|
|
158
243
|
|
|
159
244
|
if (forks.length > 0) {
|
|
@@ -162,6 +247,12 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
|
|
|
162
247
|
yield* Console.error(` ${fork.branch} → ${fork.children.join(", ")}`);
|
|
163
248
|
}
|
|
164
249
|
}
|
|
250
|
+
|
|
251
|
+
if (unclassified.length > 0 && !json) {
|
|
252
|
+
yield* warn(
|
|
253
|
+
`Skipped ${unclassified.length} unclassified branch${unclassified.length === 1 ? "" : "es"}: ${unclassified.join(", ")}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
165
256
|
}),
|
|
166
257
|
),
|
|
167
258
|
);
|
package/src/commands/doctor.ts
CHANGED
|
@@ -26,6 +26,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
26
26
|
|
|
27
27
|
const data = yield* stacks.load();
|
|
28
28
|
const findings: Finding[] = [];
|
|
29
|
+
const stackEntries = yield* stacks.listStacks().pipe(
|
|
30
|
+
Effect.catchTag("StackError", (error) =>
|
|
31
|
+
Effect.sync(() => {
|
|
32
|
+
findings.push({ type: "parse_error", message: error.message, fixed: false });
|
|
33
|
+
return [] as const;
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
29
37
|
|
|
30
38
|
// Check 1: trunk branch exists
|
|
31
39
|
const trunkExists = yield* git
|
|
@@ -58,14 +66,16 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
// Check 2: all tracked branches exist in git
|
|
61
|
-
|
|
69
|
+
const allGitBranches = yield* git
|
|
70
|
+
.listBranches()
|
|
71
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed([] as string[])));
|
|
72
|
+
const gitBranchSet = new Set(allGitBranches);
|
|
73
|
+
|
|
74
|
+
for (const { name: stackName, stack } of stackEntries) {
|
|
62
75
|
for (const branch of stack.branches) {
|
|
63
|
-
|
|
64
|
-
.branchExists(branch)
|
|
65
|
-
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
66
|
-
if (!exists) {
|
|
76
|
+
if (!gitBranchSet.has(branch)) {
|
|
67
77
|
if (fix) {
|
|
68
|
-
yield* stacks.removeBranch(
|
|
78
|
+
yield* stacks.removeBranch(branch);
|
|
69
79
|
findings.push({
|
|
70
80
|
type: "stale_branch",
|
|
71
81
|
message: `Removed stale branch "${branch}" from stack "${stackName}"`,
|
|
@@ -84,12 +94,10 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
84
94
|
|
|
85
95
|
// Check 3: no branches in multiple stacks
|
|
86
96
|
const branchToStacks = new Map<string, string[]>();
|
|
87
|
-
for (const [
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
branchToStacks.set(branch, existing);
|
|
92
|
-
}
|
|
97
|
+
for (const [branch, record] of Object.entries(data.branches)) {
|
|
98
|
+
const existing = branchToStacks.get(branch) ?? [];
|
|
99
|
+
existing.push(record.stack);
|
|
100
|
+
branchToStacks.set(branch, existing);
|
|
93
101
|
}
|
|
94
102
|
for (const [branch, stackNames] of branchToStacks) {
|
|
95
103
|
if (stackNames.length > 1) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ServiceMap } from "effect";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import type { GitHubService } from "../../services/GitHub.js";
|
|
4
|
+
|
|
5
|
+
const STACKED_MARKER_START = "<!-- stacked -->";
|
|
6
|
+
const STACKED_MARKER_END = "<!-- /stacked -->";
|
|
7
|
+
|
|
8
|
+
type PullRequest = {
|
|
9
|
+
number: number;
|
|
10
|
+
url: string;
|
|
11
|
+
state: string;
|
|
12
|
+
body?: string | null;
|
|
13
|
+
} | null;
|
|
14
|
+
|
|
15
|
+
type GitHubApi = ServiceMap.Service.Shape<typeof GitHubService>;
|
|
16
|
+
|
|
17
|
+
export const generateStackMetadata = (
|
|
18
|
+
branches: readonly string[],
|
|
19
|
+
prMap: Map<string, PullRequest>,
|
|
20
|
+
currentIdx: number,
|
|
21
|
+
stackName: string,
|
|
22
|
+
): string => {
|
|
23
|
+
const rows = branches.map((branch, i) => {
|
|
24
|
+
const pr = prMap.get(branch) ?? null;
|
|
25
|
+
const isCurrent = i === currentIdx;
|
|
26
|
+
const branchCol = isCurrent ? `**\`${branch}\`**` : `\`${branch}\``;
|
|
27
|
+
const numCol = i + 1;
|
|
28
|
+
const numStr = isCurrent ? `**${numCol}**` : `${numCol}`;
|
|
29
|
+
|
|
30
|
+
let prCol: string;
|
|
31
|
+
if (pr === null) {
|
|
32
|
+
prCol = "—";
|
|
33
|
+
} else if (pr.state === "MERGED") {
|
|
34
|
+
prCol = `[#${pr.number}](${pr.url}) ✅`;
|
|
35
|
+
} else if (isCurrent) {
|
|
36
|
+
prCol = `**#${pr.number} ← you are here**`;
|
|
37
|
+
} else {
|
|
38
|
+
prCol = `[#${pr.number}](${pr.url})`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return `| ${numStr} | ${branchCol} | ${prCol} |`;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
STACKED_MARKER_START,
|
|
46
|
+
`**Stack: \`${stackName}\`** (${currentIdx + 1} of ${branches.length})`,
|
|
47
|
+
"",
|
|
48
|
+
"| # | Branch | PR |",
|
|
49
|
+
"|---|--------|----|",
|
|
50
|
+
...rows,
|
|
51
|
+
STACKED_MARKER_END,
|
|
52
|
+
].join("\n");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const composePRBody = (userBody: string | undefined, metadata: string): string => {
|
|
56
|
+
if (userBody !== undefined) {
|
|
57
|
+
return `${userBody}\n\n---\n\n${metadata}`;
|
|
58
|
+
}
|
|
59
|
+
return metadata;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const updatePRBody = (
|
|
63
|
+
existingBody: string | undefined,
|
|
64
|
+
userBody: string | undefined,
|
|
65
|
+
metadata: string,
|
|
66
|
+
): string => {
|
|
67
|
+
if (userBody !== undefined) {
|
|
68
|
+
return composePRBody(userBody, metadata);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (existingBody !== undefined) {
|
|
72
|
+
const startIdx = existingBody.indexOf(STACKED_MARKER_START);
|
|
73
|
+
if (startIdx !== -1) {
|
|
74
|
+
const prefix = existingBody.substring(0, startIdx).replace(/\n*---\n*$/, "");
|
|
75
|
+
if (prefix.trim().length > 0) {
|
|
76
|
+
return `${prefix.trim()}\n\n---\n\n${metadata}`;
|
|
77
|
+
}
|
|
78
|
+
return metadata;
|
|
79
|
+
}
|
|
80
|
+
return `${existingBody.trim()}\n\n---\n\n${metadata}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return metadata;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const refreshStackedPRBodies = ({
|
|
87
|
+
branches,
|
|
88
|
+
stackName,
|
|
89
|
+
gh,
|
|
90
|
+
initialPrMap,
|
|
91
|
+
shouldUpdateBranch,
|
|
92
|
+
getUserBody,
|
|
93
|
+
}: {
|
|
94
|
+
branches: readonly string[];
|
|
95
|
+
stackName: string;
|
|
96
|
+
gh: GitHubApi;
|
|
97
|
+
initialPrMap?: Map<string, PullRequest>;
|
|
98
|
+
shouldUpdateBranch?: (branch: string) => boolean;
|
|
99
|
+
getUserBody?: (branch: string, idx: number) => string | undefined;
|
|
100
|
+
}) =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const prEntries = yield* Effect.forEach(
|
|
103
|
+
branches,
|
|
104
|
+
(branch) => {
|
|
105
|
+
const existing = initialPrMap?.get(branch);
|
|
106
|
+
if (existing !== undefined) {
|
|
107
|
+
return Effect.succeed([branch, existing] as const);
|
|
108
|
+
}
|
|
109
|
+
return gh.getPR(branch).pipe(Effect.map((pr) => [branch, pr] as const));
|
|
110
|
+
},
|
|
111
|
+
{ concurrency: 5 },
|
|
112
|
+
);
|
|
113
|
+
const prMap = new Map(prEntries);
|
|
114
|
+
|
|
115
|
+
// Collect all updates, then apply in parallel
|
|
116
|
+
const updates: Array<{ branch: string; body: string }> = [];
|
|
117
|
+
for (let i = 0; i < branches.length; i++) {
|
|
118
|
+
const branch = branches[i];
|
|
119
|
+
if (branch === undefined) continue;
|
|
120
|
+
if (shouldUpdateBranch !== undefined && !shouldUpdateBranch(branch)) continue;
|
|
121
|
+
|
|
122
|
+
const existingPrData = prMap.get(branch) ?? null;
|
|
123
|
+
if (existingPrData === null || existingPrData.state !== "OPEN") continue;
|
|
124
|
+
|
|
125
|
+
const metadata = generateStackMetadata(branches, prMap, i, stackName);
|
|
126
|
+
const body = updatePRBody(
|
|
127
|
+
existingPrData.body ?? undefined,
|
|
128
|
+
getUserBody?.(branch, i),
|
|
129
|
+
metadata,
|
|
130
|
+
);
|
|
131
|
+
updates.push({ branch, body });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
yield* Effect.forEach(updates, ({ branch, body }) => gh.updatePR({ branch, body }), {
|
|
135
|
+
concurrency: 5,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return prMap;
|
|
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/list.ts
CHANGED
|
@@ -25,15 +25,14 @@ export const list = Command.make("list", { stackName: stackNameArg, json: jsonFl
|
|
|
25
25
|
const stacks = yield* StackService;
|
|
26
26
|
|
|
27
27
|
const currentBranch = yield* git.currentBranch();
|
|
28
|
-
const
|
|
29
|
-
const trunk = data.trunk;
|
|
28
|
+
const trunk = yield* stacks.getTrunk();
|
|
30
29
|
|
|
31
30
|
let targetStackName: string | null = null;
|
|
32
31
|
let targetStack: { readonly branches: readonly string[] } | null = null;
|
|
33
32
|
|
|
34
33
|
if (Option.isSome(stackName)) {
|
|
35
|
-
const s =
|
|
36
|
-
if (s ===
|
|
34
|
+
const s = yield* stacks.getStack(stackName.value);
|
|
35
|
+
if (s === null) {
|
|
37
36
|
return yield* new StackError({
|
|
38
37
|
code: ErrorCode.STACK_NOT_FOUND,
|
|
39
38
|
message: `Stack "${stackName.value}" not found`,
|
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/rename.ts
CHANGED
|
@@ -16,27 +16,23 @@ export const rename = Command.make("rename", { old: oldArg, new: newArg, json: j
|
|
|
16
16
|
Command.withHandler(({ old: oldName, new: newName, json }) =>
|
|
17
17
|
Effect.gen(function* () {
|
|
18
18
|
const stacks = yield* StackService;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (data.stacks[oldName] === undefined) {
|
|
19
|
+
const existing = yield* stacks.getStack(oldName);
|
|
20
|
+
if (existing === null) {
|
|
23
21
|
return yield* new StackError({
|
|
24
22
|
code: ErrorCode.STACK_NOT_FOUND,
|
|
25
23
|
message: `Stack "${oldName}" not found`,
|
|
26
24
|
});
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
const collision = yield* stacks.getStack(newName);
|
|
28
|
+
if (collision !== null) {
|
|
30
29
|
return yield* new StackError({
|
|
31
30
|
code: ErrorCode.STACK_EXISTS,
|
|
32
31
|
message: `Stack "${newName}" already exists`,
|
|
33
32
|
});
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
if (stack === undefined) return;
|
|
38
|
-
const { [oldName]: _, ...rest } = data.stacks;
|
|
39
|
-
yield* stacks.save({ ...data, stacks: { ...rest, [newName]: stack } });
|
|
35
|
+
yield* stacks.renameStack(oldName, newName);
|
|
40
36
|
|
|
41
37
|
if (json) {
|
|
42
38
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|