@cvr/stacked 0.4.2 → 0.4.4
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 +6 -5
- package/src/commands/create.ts +6 -2
- package/src/commands/delete.ts +1 -1
- package/src/commands/detect.ts +64 -32
- package/src/commands/doctor.ts +14 -8
- package/src/commands/helpers/pr-metadata.ts +131 -0
- package/src/commands/list.ts +3 -4
- package/src/commands/rename.ts +5 -9
- package/src/commands/reorder.ts +11 -17
- package/src/commands/split.ts +4 -28
- package/src/commands/stacks.ts +3 -5
- package/src/commands/submit.ts +15 -87
- package/src/commands/sync.ts +33 -13
- package/src/main.ts +31 -29
- package/src/services/Git.ts +20 -0
- package/src/services/GitEs.ts +309 -0
- package/src/services/Stack.ts +621 -182
- package/src/services/git-backend.ts +18 -0
package/src/services/Stack.ts
CHANGED
|
@@ -4,26 +4,611 @@ import type { GitError } from "../errors/index.js";
|
|
|
4
4
|
import { StackError } from "../errors/index.js";
|
|
5
5
|
import { GitService } from "./Git.js";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const StackV1Schema = Schema.Struct({
|
|
8
8
|
branches: Schema.Array(Schema.String),
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const StackFileV1Schema = Schema.Struct({
|
|
12
12
|
version: Schema.Literal(1),
|
|
13
13
|
trunk: Schema.String,
|
|
14
|
-
stacks: Schema.Record(Schema.String,
|
|
14
|
+
stacks: Schema.Record(Schema.String, StackV1Schema),
|
|
15
|
+
mergedBranches: Schema.optional(Schema.Array(Schema.String)),
|
|
15
16
|
});
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const StackRecordSchema = Schema.Struct({
|
|
19
|
+
root: Schema.String,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const BranchRecordSchema = Schema.Struct({
|
|
23
|
+
stack: Schema.String,
|
|
24
|
+
parent: Schema.NullOr(Schema.String),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const StackFileV2Schema = Schema.Struct({
|
|
28
|
+
version: Schema.Literal(2),
|
|
29
|
+
trunk: Schema.String,
|
|
30
|
+
stacks: Schema.Record(Schema.String, StackRecordSchema),
|
|
31
|
+
branches: Schema.Record(Schema.String, BranchRecordSchema),
|
|
32
|
+
mergedBranches: Schema.optional(Schema.Array(Schema.String)),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const StackFilePersistSchema = Schema.Struct({
|
|
36
|
+
version: Schema.Literal(2),
|
|
37
|
+
trunk: Schema.String,
|
|
38
|
+
stacks: Schema.Record(Schema.String, StackRecordSchema),
|
|
39
|
+
branches: Schema.Record(Schema.String, BranchRecordSchema),
|
|
40
|
+
mergedBranches: Schema.Array(Schema.String),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type StackFileV1 = typeof StackFileV1Schema.Type;
|
|
44
|
+
type StackFileV2Input = typeof StackFileV2Schema.Type;
|
|
45
|
+
export type StackFile = StackFileV1 | StackFileV2Input;
|
|
46
|
+
|
|
47
|
+
export interface Stack {
|
|
48
|
+
readonly root: string;
|
|
49
|
+
readonly branches: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface CanonicalStackFile {
|
|
53
|
+
readonly version: 2;
|
|
54
|
+
readonly trunk: string;
|
|
55
|
+
readonly stacks: Readonly<Record<string, { readonly root: string }>>;
|
|
56
|
+
readonly branches: Readonly<
|
|
57
|
+
Record<string, { readonly stack: string; readonly parent: string | null }>
|
|
58
|
+
>;
|
|
59
|
+
readonly mergedBranches: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface StackSnapshot {
|
|
63
|
+
readonly stacks: Readonly<Record<string, Stack>>;
|
|
64
|
+
readonly branchToStack: ReadonlyMap<string, { readonly name: string; readonly stack: Stack }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface SplitResult {
|
|
68
|
+
readonly original: { readonly name: string; readonly branches: readonly string[] };
|
|
69
|
+
readonly created: { readonly name: string; readonly branches: readonly string[] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const emptyStackFile: CanonicalStackFile = {
|
|
73
|
+
version: 2,
|
|
74
|
+
trunk: "main",
|
|
75
|
+
stacks: {},
|
|
76
|
+
branches: {},
|
|
77
|
+
mergedBranches: [],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const sortUnique = (values: readonly string[]) => [...new Set(values)].sort();
|
|
81
|
+
|
|
82
|
+
const migrateV1ToV2 = (input: StackFileV1): CanonicalStackFile => {
|
|
83
|
+
const stacks: Record<string, { root: string }> = {};
|
|
84
|
+
const branches: Record<string, { stack: string; parent: string | null }> = {};
|
|
85
|
+
|
|
86
|
+
for (const [name, stack] of Object.entries(input.stacks)) {
|
|
87
|
+
const [root] = stack.branches;
|
|
88
|
+
if (root === undefined) continue;
|
|
89
|
+
|
|
90
|
+
stacks[name] = { root };
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < stack.branches.length; i++) {
|
|
93
|
+
const branch = stack.branches[i];
|
|
94
|
+
if (branch === undefined) continue;
|
|
95
|
+
branches[branch] = {
|
|
96
|
+
stack: name,
|
|
97
|
+
parent: i === 0 ? null : (stack.branches[i - 1] ?? null),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
version: 2,
|
|
104
|
+
trunk: input.trunk,
|
|
105
|
+
stacks,
|
|
106
|
+
branches,
|
|
107
|
+
mergedBranches: sortUnique(
|
|
108
|
+
(input.mergedBranches ?? []).filter((branch) => branches[branch] === undefined),
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const normalizeStackFile = (input: StackFile): CanonicalStackFile => {
|
|
114
|
+
const migrated = input.version === 1 ? migrateV1ToV2(input) : input;
|
|
115
|
+
return {
|
|
116
|
+
version: 2,
|
|
117
|
+
trunk: migrated.trunk,
|
|
118
|
+
stacks: migrated.stacks,
|
|
119
|
+
branches: migrated.branches,
|
|
120
|
+
mergedBranches: sortUnique(migrated.mergedBranches ?? []),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const projectStacks = (data: CanonicalStackFile): Effect.Effect<StackSnapshot, StackError> =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const branchesByStack = new Map<string, string[]>();
|
|
127
|
+
|
|
128
|
+
for (const [branch, record] of Object.entries(data.branches)) {
|
|
129
|
+
if (data.stacks[record.stack] === undefined) {
|
|
130
|
+
return yield* new StackError({
|
|
131
|
+
message: `Branch "${branch}" points at missing stack "${record.stack}". Run 'stacked doctor --fix'.`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const existing = branchesByStack.get(record.stack) ?? [];
|
|
135
|
+
existing.push(branch);
|
|
136
|
+
branchesByStack.set(record.stack, existing);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stacks: Record<string, Stack> = {};
|
|
140
|
+
const branchToStack = new Map<string, { name: string; stack: Stack }>();
|
|
141
|
+
|
|
142
|
+
for (const [name, stackRecord] of Object.entries(data.stacks)) {
|
|
143
|
+
const stackBranches = branchesByStack.get(name) ?? [];
|
|
144
|
+
if (stackBranches.length === 0) {
|
|
145
|
+
return yield* new StackError({
|
|
146
|
+
message: `Stack "${name}" has no branches. Run 'stacked doctor --fix'.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rootRecord = data.branches[stackRecord.root];
|
|
151
|
+
if (rootRecord === undefined || rootRecord.stack !== name || rootRecord.parent !== null) {
|
|
152
|
+
return yield* new StackError({
|
|
153
|
+
message: `Stack "${name}" has invalid root "${stackRecord.root}". Run 'stacked doctor --fix'.`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const childrenByParent = new Map<string | null, string[]>();
|
|
158
|
+
for (const branch of stackBranches) {
|
|
159
|
+
const record = data.branches[branch];
|
|
160
|
+
if (record === undefined) continue;
|
|
161
|
+
|
|
162
|
+
if (record.parent !== null) {
|
|
163
|
+
const parent = data.branches[record.parent];
|
|
164
|
+
if (parent === undefined || parent.stack !== name) {
|
|
165
|
+
return yield* new StackError({
|
|
166
|
+
message: `Branch "${branch}" points at invalid parent "${record.parent}". Run 'stacked doctor --fix'.`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const children = childrenByParent.get(record.parent) ?? [];
|
|
172
|
+
children.push(branch);
|
|
173
|
+
childrenByParent.set(record.parent, children);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rootChildren = childrenByParent.get(null) ?? [];
|
|
177
|
+
if (rootChildren.length !== 1 || rootChildren[0] !== stackRecord.root) {
|
|
178
|
+
return yield* new StackError({
|
|
179
|
+
message: `Stack "${name}" has an invalid root chain. Run 'stacked doctor --fix'.`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const ordered: string[] = [];
|
|
184
|
+
const visited = new Set<string>();
|
|
185
|
+
let current: string | undefined = stackRecord.root;
|
|
186
|
+
|
|
187
|
+
while (current !== undefined) {
|
|
188
|
+
if (visited.has(current)) {
|
|
189
|
+
return yield* new StackError({
|
|
190
|
+
message: `Stack "${name}" contains a cycle at "${current}". Run 'stacked doctor --fix'.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
visited.add(current);
|
|
194
|
+
ordered.push(current);
|
|
195
|
+
|
|
196
|
+
const children: string[] = childrenByParent.get(current) ?? [];
|
|
197
|
+
if (children.length > 1) {
|
|
198
|
+
return yield* new StackError({
|
|
199
|
+
message: `Stack "${name}" forks at "${current}". Run 'stacked doctor --fix'.`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
current = children[0];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (ordered.length !== stackBranches.length) {
|
|
207
|
+
return yield* new StackError({
|
|
208
|
+
message: `Stack "${name}" is disconnected. Run 'stacked doctor --fix'.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const stack: Stack = { root: stackRecord.root, branches: ordered };
|
|
213
|
+
stacks[name] = stack;
|
|
214
|
+
for (const branch of ordered) {
|
|
215
|
+
branchToStack.set(branch, { name, stack });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { stacks, branchToStack };
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const rewriteStackBranches = (
|
|
223
|
+
data: CanonicalStackFile,
|
|
224
|
+
stackName: string,
|
|
225
|
+
branches: readonly string[],
|
|
226
|
+
): CanonicalStackFile => {
|
|
227
|
+
const nextBranches: Record<string, { stack: string; parent: string | null }> = {
|
|
228
|
+
...data.branches,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
for (const [branch, record] of Object.entries(nextBranches)) {
|
|
232
|
+
if (record.stack === stackName) {
|
|
233
|
+
delete nextBranches[branch];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (let i = 0; i < branches.length; i++) {
|
|
238
|
+
const branch = branches[i];
|
|
239
|
+
if (branch === undefined) continue;
|
|
240
|
+
nextBranches[branch] = {
|
|
241
|
+
stack: stackName,
|
|
242
|
+
parent: i === 0 ? null : (branches[i - 1] ?? null),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (branches.length === 0) {
|
|
247
|
+
const { [stackName]: _, ...restStacks } = data.stacks;
|
|
248
|
+
return {
|
|
249
|
+
...data,
|
|
250
|
+
stacks: restStacks,
|
|
251
|
+
branches: nextBranches,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const [root] = branches;
|
|
256
|
+
if (root === undefined) {
|
|
257
|
+
return data;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
...data,
|
|
262
|
+
stacks: {
|
|
263
|
+
...data.stacks,
|
|
264
|
+
[stackName]: { root },
|
|
265
|
+
},
|
|
266
|
+
branches: nextBranches,
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const renameStackRefs = (
|
|
271
|
+
data: CanonicalStackFile,
|
|
272
|
+
oldName: string,
|
|
273
|
+
newName: string,
|
|
274
|
+
): CanonicalStackFile => {
|
|
275
|
+
const stackRecord = data.stacks[oldName];
|
|
276
|
+
if (stackRecord === undefined) return data;
|
|
277
|
+
|
|
278
|
+
const { [oldName]: _, ...restStacks } = data.stacks;
|
|
279
|
+
const branches: Record<string, { stack: string; parent: string | null }> = {};
|
|
280
|
+
|
|
281
|
+
for (const [branch, record] of Object.entries(data.branches)) {
|
|
282
|
+
branches[branch] = {
|
|
283
|
+
...record,
|
|
284
|
+
stack: record.stack === oldName ? newName : record.stack,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
...data,
|
|
290
|
+
stacks: {
|
|
291
|
+
...restStacks,
|
|
292
|
+
[newName]: stackRecord,
|
|
293
|
+
},
|
|
294
|
+
branches,
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
interface StackServiceFactoryOptions {
|
|
299
|
+
readonly loadData: () => Effect.Effect<CanonicalStackFile, StackError>;
|
|
300
|
+
readonly saveData: (data: CanonicalStackFile) => Effect.Effect<void, StackError>;
|
|
301
|
+
readonly currentBranch: () => Effect.Effect<string, StackError | GitError>;
|
|
302
|
+
readonly detectTrunkCandidate: () => Effect.Effect<Option.Option<string>, never>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const makeStackService = ({
|
|
306
|
+
loadData,
|
|
307
|
+
saveData,
|
|
308
|
+
currentBranch,
|
|
309
|
+
detectTrunkCandidate,
|
|
310
|
+
}: StackServiceFactoryOptions): ServiceMap.Service.Shape<typeof StackService> => {
|
|
311
|
+
const snapshot = () => loadData().pipe(Effect.flatMap(projectStacks));
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
load: () => loadData(),
|
|
315
|
+
save: (data) => saveData(normalizeStackFile(data)),
|
|
316
|
+
|
|
317
|
+
listStacks: () =>
|
|
318
|
+
snapshot().pipe(
|
|
319
|
+
Effect.map((state) =>
|
|
320
|
+
Object.entries(state.stacks).map(([name, stack]) => ({ name, stack })),
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
|
|
324
|
+
getStack: (name) => snapshot().pipe(Effect.map((state) => state.stacks[name] ?? null)),
|
|
325
|
+
|
|
326
|
+
trackedBranches: () => loadData().pipe(Effect.map((data) => Object.keys(data.branches).sort())),
|
|
327
|
+
|
|
328
|
+
findBranchStack: (branch) =>
|
|
329
|
+
snapshot().pipe(Effect.map((state) => state.branchToStack.get(branch) ?? null)),
|
|
330
|
+
|
|
331
|
+
detectTrunkCandidate: () => detectTrunkCandidate(),
|
|
332
|
+
|
|
333
|
+
currentStack: Effect.fn("StackService.currentStack")(function* () {
|
|
334
|
+
const branch = yield* currentBranch();
|
|
335
|
+
const state = yield* snapshot();
|
|
336
|
+
return state.branchToStack.get(branch) ?? null;
|
|
337
|
+
}),
|
|
338
|
+
|
|
339
|
+
addBranch: Effect.fn("StackService.addBranch")(function* (
|
|
340
|
+
stackName: string,
|
|
341
|
+
branch: string,
|
|
342
|
+
after?: string,
|
|
343
|
+
) {
|
|
344
|
+
const data = yield* loadData();
|
|
345
|
+
const state = yield* projectStacks(data);
|
|
346
|
+
|
|
347
|
+
if (data.branches[branch] !== undefined) {
|
|
348
|
+
const existing = state.branchToStack.get(branch);
|
|
349
|
+
return yield* new StackError({
|
|
350
|
+
message: `Branch "${branch}" is already in stack "${existing?.name ?? data.branches[branch]?.stack ?? "unknown"}"`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const stack = state.stacks[stackName];
|
|
355
|
+
if (stack === undefined) {
|
|
356
|
+
return yield* new StackError({ message: `Stack "${stackName}" not found` });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const branches = [...stack.branches];
|
|
360
|
+
if (after !== undefined) {
|
|
361
|
+
const idx = branches.indexOf(after);
|
|
362
|
+
if (idx === -1) {
|
|
363
|
+
return yield* new StackError({
|
|
364
|
+
message: `Branch "${after}" not in stack "${stackName}"`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
branches.splice(idx + 1, 0, branch);
|
|
368
|
+
} else {
|
|
369
|
+
branches.push(branch);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const next = rewriteStackBranches(data, stackName, branches);
|
|
373
|
+
yield* saveData({
|
|
374
|
+
...next,
|
|
375
|
+
mergedBranches: next.mergedBranches.filter((name) => name !== branch),
|
|
376
|
+
});
|
|
377
|
+
}),
|
|
378
|
+
|
|
379
|
+
removeBranch: Effect.fn("StackService.removeBranch")(function* (branch: string) {
|
|
380
|
+
const data = yield* loadData();
|
|
381
|
+
const state = yield* projectStacks(data);
|
|
382
|
+
const resolved = state.branchToStack.get(branch);
|
|
383
|
+
if (resolved === undefined) {
|
|
384
|
+
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const stackName = resolved.name;
|
|
388
|
+
const stack = resolved.stack;
|
|
389
|
+
const remaining = stack.branches.filter((name) => name !== branch);
|
|
390
|
+
const { [branch]: _, ...remainingBranches } = data.branches;
|
|
391
|
+
|
|
392
|
+
if (remaining.length === 0) {
|
|
393
|
+
const { [stackName]: __, ...restStacks } = data.stacks;
|
|
394
|
+
yield* saveData({
|
|
395
|
+
...data,
|
|
396
|
+
stacks: restStacks,
|
|
397
|
+
branches: remainingBranches,
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let next: CanonicalStackFile = {
|
|
403
|
+
...data,
|
|
404
|
+
branches: remainingBranches,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
if (branch === stack.root && stackName === stack.root) {
|
|
408
|
+
const [nextRoot] = remaining;
|
|
409
|
+
if (nextRoot !== undefined) {
|
|
410
|
+
next = renameStackRefs(next, stackName, nextRoot);
|
|
411
|
+
yield* saveData(rewriteStackBranches(next, nextRoot, remaining));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
yield* saveData(rewriteStackBranches(next, stackName, remaining));
|
|
417
|
+
}),
|
|
418
|
+
|
|
419
|
+
createStack: Effect.fn("StackService.createStack")(function* (
|
|
420
|
+
name: string,
|
|
421
|
+
branches: string[],
|
|
422
|
+
) {
|
|
423
|
+
if (branches.length === 0) {
|
|
424
|
+
return yield* new StackError({
|
|
425
|
+
message: `Stack "${name}" must contain at least one branch`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const data = yield* loadData();
|
|
430
|
+
if (data.stacks[name] !== undefined) {
|
|
431
|
+
return yield* new StackError({ message: `Stack "${name}" already exists` });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const branch of branches) {
|
|
435
|
+
const existing = data.branches[branch];
|
|
436
|
+
if (existing !== undefined) {
|
|
437
|
+
return yield* new StackError({
|
|
438
|
+
message: `Branch "${branch}" is already in stack "${existing.stack}"`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const next = rewriteStackBranches(
|
|
444
|
+
{
|
|
445
|
+
...data,
|
|
446
|
+
stacks: {
|
|
447
|
+
...data.stacks,
|
|
448
|
+
[name]: { root: branches[0] ?? name },
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
name,
|
|
452
|
+
branches,
|
|
453
|
+
);
|
|
454
|
+
yield* saveData({
|
|
455
|
+
...next,
|
|
456
|
+
mergedBranches: next.mergedBranches.filter((branch) => !branches.includes(branch)),
|
|
457
|
+
});
|
|
458
|
+
}),
|
|
459
|
+
|
|
460
|
+
renameStack: Effect.fn("StackService.renameStack")(function* (
|
|
461
|
+
oldName: string,
|
|
462
|
+
newName: string,
|
|
463
|
+
) {
|
|
464
|
+
const data = yield* loadData();
|
|
465
|
+
if (data.stacks[oldName] === undefined) {
|
|
466
|
+
return yield* new StackError({ message: `Stack "${oldName}" not found` });
|
|
467
|
+
}
|
|
468
|
+
if (data.stacks[newName] !== undefined) {
|
|
469
|
+
return yield* new StackError({ message: `Stack "${newName}" already exists` });
|
|
470
|
+
}
|
|
471
|
+
yield* saveData(renameStackRefs(data, oldName, newName));
|
|
472
|
+
}),
|
|
473
|
+
|
|
474
|
+
splitStack: Effect.fn("StackService.splitStack")(function* (branch: string) {
|
|
475
|
+
const data = yield* loadData();
|
|
476
|
+
const state = yield* projectStacks(data);
|
|
477
|
+
const resolved = state.branchToStack.get(branch);
|
|
478
|
+
if (resolved === undefined) {
|
|
479
|
+
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const stackName = resolved.name;
|
|
483
|
+
const branches = [...resolved.stack.branches];
|
|
484
|
+
const splitIndex = branches.indexOf(branch);
|
|
485
|
+
if (splitIndex <= 0) {
|
|
486
|
+
return yield* new StackError({
|
|
487
|
+
message: `Branch "${branch}" is at the bottom of the stack — nothing to split`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (data.stacks[branch] !== undefined) {
|
|
492
|
+
return yield* new StackError({
|
|
493
|
+
message: `Stack "${branch}" already exists — choose a different split point or rename it first`,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const original = branches.slice(0, splitIndex);
|
|
498
|
+
const created = branches.slice(splitIndex);
|
|
499
|
+
let next = rewriteStackBranches(data, stackName, original);
|
|
500
|
+
next = rewriteStackBranches(
|
|
501
|
+
{
|
|
502
|
+
...next,
|
|
503
|
+
stacks: {
|
|
504
|
+
...next.stacks,
|
|
505
|
+
[branch]: { root: branch },
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
branch,
|
|
509
|
+
created,
|
|
510
|
+
);
|
|
511
|
+
yield* saveData(next);
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
original: { name: stackName, branches: original },
|
|
515
|
+
created: { name: branch, branches: created },
|
|
516
|
+
} satisfies SplitResult;
|
|
517
|
+
}),
|
|
518
|
+
|
|
519
|
+
reorderBranch: Effect.fn("StackService.reorderBranch")(function* (
|
|
520
|
+
branch: string,
|
|
521
|
+
position: { before?: string; after?: string },
|
|
522
|
+
) {
|
|
523
|
+
const data = yield* loadData();
|
|
524
|
+
const state = yield* projectStacks(data);
|
|
525
|
+
const resolved = state.branchToStack.get(branch);
|
|
526
|
+
if (resolved === undefined) {
|
|
527
|
+
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const stackName = resolved.name;
|
|
531
|
+
const branches = [...resolved.stack.branches];
|
|
532
|
+
const currentIdx = branches.indexOf(branch);
|
|
533
|
+
if (currentIdx === -1) {
|
|
534
|
+
return yield* new StackError({
|
|
535
|
+
message: `Branch "${branch}" not found in stack "${stackName}"`,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const target = position.before ?? position.after;
|
|
540
|
+
if (target === undefined) {
|
|
541
|
+
return yield* new StackError({
|
|
542
|
+
message: "Specify --before or --after to indicate target position",
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const targetIdx = branches.indexOf(target);
|
|
547
|
+
if (targetIdx === -1) {
|
|
548
|
+
return yield* new StackError({
|
|
549
|
+
message: `Branch "${target}" not found in stack "${stackName}"`,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
branches.splice(currentIdx, 1);
|
|
554
|
+
const nextTargetIdx = branches.indexOf(target);
|
|
555
|
+
branches.splice(position.before !== undefined ? nextTargetIdx : nextTargetIdx + 1, 0, branch);
|
|
19
556
|
|
|
20
|
-
const
|
|
557
|
+
const next = rewriteStackBranches(data, stackName, branches);
|
|
558
|
+
yield* saveData(next);
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
name: stackName,
|
|
562
|
+
stack: { root: branches[0] ?? resolved.stack.root, branches },
|
|
563
|
+
};
|
|
564
|
+
}),
|
|
565
|
+
|
|
566
|
+
markMergedBranches: Effect.fn("StackService.markMergedBranches")(function* (
|
|
567
|
+
branches: readonly string[],
|
|
568
|
+
) {
|
|
569
|
+
if (branches.length === 0) return;
|
|
570
|
+
const data = yield* loadData();
|
|
571
|
+
yield* saveData({
|
|
572
|
+
...data,
|
|
573
|
+
mergedBranches: sortUnique([...data.mergedBranches, ...branches]),
|
|
574
|
+
});
|
|
575
|
+
}),
|
|
576
|
+
|
|
577
|
+
unmarkMergedBranches: Effect.fn("StackService.unmarkMergedBranches")(function* (
|
|
578
|
+
branches: readonly string[],
|
|
579
|
+
) {
|
|
580
|
+
if (branches.length === 0) return;
|
|
581
|
+
const branchSet = new Set(branches);
|
|
582
|
+
const data = yield* loadData();
|
|
583
|
+
yield* saveData({
|
|
584
|
+
...data,
|
|
585
|
+
mergedBranches: data.mergedBranches.filter((branch) => !branchSet.has(branch)),
|
|
586
|
+
});
|
|
587
|
+
}),
|
|
588
|
+
|
|
589
|
+
getTrunk: Effect.fn("StackService.getTrunk")(function* () {
|
|
590
|
+
const data = yield* loadData();
|
|
591
|
+
return data.trunk;
|
|
592
|
+
}),
|
|
593
|
+
|
|
594
|
+
setTrunk: Effect.fn("StackService.setTrunk")(function* (name: string) {
|
|
595
|
+
const data = yield* loadData();
|
|
596
|
+
yield* saveData({ ...data, trunk: name });
|
|
597
|
+
}),
|
|
598
|
+
};
|
|
599
|
+
};
|
|
21
600
|
|
|
22
601
|
export class StackService extends ServiceMap.Service<
|
|
23
602
|
StackService,
|
|
24
603
|
{
|
|
25
|
-
readonly load: () => Effect.Effect<
|
|
604
|
+
readonly load: () => Effect.Effect<CanonicalStackFile, StackError>;
|
|
26
605
|
readonly save: (data: StackFile) => Effect.Effect<void, StackError>;
|
|
606
|
+
readonly listStacks: () => Effect.Effect<
|
|
607
|
+
ReadonlyArray<{ readonly name: string; readonly stack: Stack }>,
|
|
608
|
+
StackError
|
|
609
|
+
>;
|
|
610
|
+
readonly getStack: (name: string) => Effect.Effect<Stack | null, StackError>;
|
|
611
|
+
readonly trackedBranches: () => Effect.Effect<readonly string[], StackError>;
|
|
27
612
|
readonly currentStack: () => Effect.Effect<
|
|
28
613
|
{ name: string; stack: Stack } | null,
|
|
29
614
|
StackError | GitError
|
|
@@ -33,8 +618,16 @@ export class StackService extends ServiceMap.Service<
|
|
|
33
618
|
branch: string,
|
|
34
619
|
after?: string,
|
|
35
620
|
) => Effect.Effect<void, StackError>;
|
|
36
|
-
readonly removeBranch: (
|
|
621
|
+
readonly removeBranch: (branch: string) => Effect.Effect<void, StackError>;
|
|
37
622
|
readonly createStack: (name: string, branches: string[]) => Effect.Effect<void, StackError>;
|
|
623
|
+
readonly renameStack: (oldName: string, newName: string) => Effect.Effect<void, StackError>;
|
|
624
|
+
readonly splitStack: (branch: string) => Effect.Effect<SplitResult, StackError>;
|
|
625
|
+
readonly reorderBranch: (
|
|
626
|
+
branch: string,
|
|
627
|
+
position: { before?: string; after?: string },
|
|
628
|
+
) => Effect.Effect<{ name: string; stack: Stack }, StackError>;
|
|
629
|
+
readonly markMergedBranches: (branches: readonly string[]) => Effect.Effect<void, StackError>;
|
|
630
|
+
readonly unmarkMergedBranches: (branches: readonly string[]) => Effect.Effect<void, StackError>;
|
|
38
631
|
readonly findBranchStack: (
|
|
39
632
|
branch: string,
|
|
40
633
|
) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
|
|
@@ -48,7 +641,7 @@ export class StackService extends ServiceMap.Service<
|
|
|
48
641
|
Effect.gen(function* () {
|
|
49
642
|
const git = yield* GitService;
|
|
50
643
|
|
|
51
|
-
const stackFilePath = Effect.fn("stackFilePath")(function* () {
|
|
644
|
+
const stackFilePath = Effect.fn("StackService.stackFilePath")(function* () {
|
|
52
645
|
const gitDir = yield* git
|
|
53
646
|
.revParse("--absolute-git-dir")
|
|
54
647
|
.pipe(
|
|
@@ -59,9 +652,12 @@ export class StackService extends ServiceMap.Service<
|
|
|
59
652
|
return `${gitDir}/stacked.json`;
|
|
60
653
|
});
|
|
61
654
|
|
|
62
|
-
const StackFileJson = Schema.fromJsonString(
|
|
655
|
+
const StackFileJson = Schema.fromJsonString(
|
|
656
|
+
Schema.Union([StackFileV1Schema, StackFileV2Schema]),
|
|
657
|
+
);
|
|
658
|
+
const PersistStackFileJson = Schema.fromJsonString(StackFilePersistSchema);
|
|
63
659
|
const decodeStackFile = Schema.decodeUnknownEffect(StackFileJson);
|
|
64
|
-
const encodeStackFile = Schema.encodeEffect(
|
|
660
|
+
const encodeStackFile = Schema.encodeEffect(PersistStackFileJson);
|
|
65
661
|
|
|
66
662
|
const detectTrunkCandidate = Effect.fn("StackService.detectTrunkCandidate")(function* () {
|
|
67
663
|
const remoteDefault = yield* git.remoteDefaultBranch("origin");
|
|
@@ -102,6 +698,7 @@ export class StackService extends ServiceMap.Service<
|
|
|
102
698
|
catch: () => new StackError({ message: `Failed to read ${path}` }),
|
|
103
699
|
});
|
|
104
700
|
return yield* decodeStackFile(text).pipe(
|
|
701
|
+
Effect.map((data) => normalizeStackFile(data as StackFile)),
|
|
105
702
|
Effect.catchTag("SchemaError", (e) =>
|
|
106
703
|
Effect.gen(function* () {
|
|
107
704
|
const backupPath = `${path}.backup`;
|
|
@@ -122,7 +719,7 @@ export class StackService extends ServiceMap.Service<
|
|
|
122
719
|
const save = Effect.fn("StackService.save")(function* (data: StackFile) {
|
|
123
720
|
const path = yield* stackFilePath();
|
|
124
721
|
const tmpPath = `${path}.tmp`;
|
|
125
|
-
const text = yield* encodeStackFile(data).pipe(
|
|
722
|
+
const text = yield* encodeStackFile(normalizeStackFile(data)).pipe(
|
|
126
723
|
Effect.mapError(() => new StackError({ message: `Failed to encode stack data` })),
|
|
127
724
|
);
|
|
128
725
|
yield* Effect.tryPromise({
|
|
@@ -135,108 +732,12 @@ export class StackService extends ServiceMap.Service<
|
|
|
135
732
|
});
|
|
136
733
|
});
|
|
137
734
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return null;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
load: () => load(),
|
|
149
|
-
save: (data) => save(data),
|
|
150
|
-
|
|
151
|
-
findBranchStack: (branch: string) =>
|
|
152
|
-
load().pipe(Effect.map((data) => findBranchStack(data, branch))),
|
|
735
|
+
return makeStackService({
|
|
736
|
+
loadData: () => load(),
|
|
737
|
+
saveData: (data) => save(data),
|
|
738
|
+
currentBranch: () => git.currentBranch(),
|
|
153
739
|
detectTrunkCandidate: () => detectTrunkCandidate(),
|
|
154
|
-
|
|
155
|
-
currentStack: Effect.fn("StackService.currentStack")(function* () {
|
|
156
|
-
const branch = yield* git.currentBranch();
|
|
157
|
-
const data = yield* load();
|
|
158
|
-
return findBranchStack(data, branch);
|
|
159
|
-
}),
|
|
160
|
-
|
|
161
|
-
addBranch: Effect.fn("StackService.addBranch")(function* (
|
|
162
|
-
stackName: string,
|
|
163
|
-
branch: string,
|
|
164
|
-
after?: string,
|
|
165
|
-
) {
|
|
166
|
-
const data = yield* load();
|
|
167
|
-
const existing = findBranchStack(data, branch);
|
|
168
|
-
if (existing !== null) {
|
|
169
|
-
return yield* new StackError({
|
|
170
|
-
message: `Branch "${branch}" is already in stack "${existing.name}"`,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
const stack = data.stacks[stackName];
|
|
174
|
-
if (stack === undefined) {
|
|
175
|
-
return yield* new StackError({ message: `Stack "${stackName}" not found` });
|
|
176
|
-
}
|
|
177
|
-
const branches = [...stack.branches];
|
|
178
|
-
if (after !== undefined) {
|
|
179
|
-
const idx = branches.indexOf(after);
|
|
180
|
-
if (idx === -1) {
|
|
181
|
-
return yield* new StackError({
|
|
182
|
-
message: `Branch "${after}" not in stack "${stackName}"`,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
branches.splice(idx + 1, 0, branch);
|
|
186
|
-
} else {
|
|
187
|
-
branches.push(branch);
|
|
188
|
-
}
|
|
189
|
-
yield* save({
|
|
190
|
-
...data,
|
|
191
|
-
stacks: { ...data.stacks, [stackName]: { branches } },
|
|
192
|
-
});
|
|
193
|
-
}),
|
|
194
|
-
|
|
195
|
-
removeBranch: Effect.fn("StackService.removeBranch")(function* (
|
|
196
|
-
stackName: string,
|
|
197
|
-
branch: string,
|
|
198
|
-
) {
|
|
199
|
-
const data = yield* load();
|
|
200
|
-
const stack = data.stacks[stackName];
|
|
201
|
-
if (stack === undefined) {
|
|
202
|
-
return yield* new StackError({ message: `Stack "${stackName}" not found` });
|
|
203
|
-
}
|
|
204
|
-
const branches = stack.branches.filter((b) => b !== branch);
|
|
205
|
-
if (branches.length === 0) {
|
|
206
|
-
const { [stackName]: _, ...rest } = data.stacks;
|
|
207
|
-
yield* save({ ...data, stacks: rest });
|
|
208
|
-
} else {
|
|
209
|
-
yield* save({
|
|
210
|
-
...data,
|
|
211
|
-
stacks: { ...data.stacks, [stackName]: { branches } },
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}),
|
|
215
|
-
|
|
216
|
-
createStack: Effect.fn("StackService.createStack")(function* (
|
|
217
|
-
name: string,
|
|
218
|
-
branches: string[],
|
|
219
|
-
) {
|
|
220
|
-
const data = yield* load();
|
|
221
|
-
if (data.stacks[name] !== undefined) {
|
|
222
|
-
return yield* new StackError({ message: `Stack "${name}" already exists` });
|
|
223
|
-
}
|
|
224
|
-
yield* save({
|
|
225
|
-
...data,
|
|
226
|
-
stacks: { ...data.stacks, [name]: { branches } },
|
|
227
|
-
});
|
|
228
|
-
}),
|
|
229
|
-
|
|
230
|
-
getTrunk: Effect.fn("StackService.getTrunk")(function* () {
|
|
231
|
-
const data = yield* load();
|
|
232
|
-
return data.trunk;
|
|
233
|
-
}),
|
|
234
|
-
|
|
235
|
-
setTrunk: Effect.fn("StackService.setTrunk")(function* (name: string) {
|
|
236
|
-
const data = yield* load();
|
|
237
|
-
yield* save({ ...data, trunk: name });
|
|
238
|
-
}),
|
|
239
|
-
};
|
|
740
|
+
});
|
|
240
741
|
}),
|
|
241
742
|
);
|
|
242
743
|
|
|
@@ -244,85 +745,23 @@ export class StackService extends ServiceMap.Service<
|
|
|
244
745
|
data?: StackFile,
|
|
245
746
|
options?: { currentBranch?: string; detectTrunkCandidate?: Option.Option<string> },
|
|
246
747
|
) => {
|
|
247
|
-
const initial = data ?? emptyStackFile;
|
|
748
|
+
const initial = normalizeStackFile(data ?? emptyStackFile);
|
|
248
749
|
return Layer.effect(
|
|
249
750
|
StackService,
|
|
250
751
|
Effect.gen(function* () {
|
|
251
|
-
const ref = yield* Ref.make<
|
|
252
|
-
|
|
253
|
-
const findBranchStack = (d: StackFile, branch: string) => {
|
|
254
|
-
for (const [name, stack] of Object.entries(d.stacks)) {
|
|
255
|
-
if (stack.branches.includes(branch)) {
|
|
256
|
-
return { name, stack };
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return null;
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
return {
|
|
263
|
-
load: () => Ref.get(ref),
|
|
264
|
-
save: (d) => Ref.set(ref, d),
|
|
265
|
-
|
|
266
|
-
findBranchStack: (branch: string) =>
|
|
267
|
-
Ref.get(ref).pipe(Effect.map((d) => findBranchStack(d, branch))),
|
|
268
|
-
|
|
269
|
-
currentStack: Effect.fn("test.currentStack")(function* () {
|
|
270
|
-
const d = yield* Ref.get(ref);
|
|
271
|
-
return findBranchStack(d, options?.currentBranch ?? "test-branch");
|
|
272
|
-
}),
|
|
273
|
-
|
|
274
|
-
addBranch: Effect.fn("test.addBranch")(function* (
|
|
275
|
-
stackName: string,
|
|
276
|
-
branch: string,
|
|
277
|
-
after?: string,
|
|
278
|
-
) {
|
|
279
|
-
yield* Ref.update(ref, (d) => {
|
|
280
|
-
const stack = d.stacks[stackName];
|
|
281
|
-
if (stack === undefined) return d;
|
|
282
|
-
const branches = [...stack.branches];
|
|
283
|
-
if (after !== undefined) {
|
|
284
|
-
const idx = branches.indexOf(after);
|
|
285
|
-
if (idx !== -1) branches.splice(idx + 1, 0, branch);
|
|
286
|
-
else branches.push(branch);
|
|
287
|
-
} else {
|
|
288
|
-
branches.push(branch);
|
|
289
|
-
}
|
|
290
|
-
return { ...d, stacks: { ...d.stacks, [stackName]: { branches } } };
|
|
291
|
-
});
|
|
292
|
-
}),
|
|
293
|
-
|
|
294
|
-
removeBranch: Effect.fn("test.removeBranch")(function* (
|
|
295
|
-
stackName: string,
|
|
296
|
-
branch: string,
|
|
297
|
-
) {
|
|
298
|
-
yield* Ref.update(ref, (d) => {
|
|
299
|
-
const stack = d.stacks[stackName];
|
|
300
|
-
if (stack === undefined) return d;
|
|
301
|
-
const branches = stack.branches.filter((b) => b !== branch);
|
|
302
|
-
if (branches.length === 0) {
|
|
303
|
-
const { [stackName]: _, ...rest } = d.stacks;
|
|
304
|
-
return { ...d, stacks: rest };
|
|
305
|
-
}
|
|
306
|
-
return { ...d, stacks: { ...d.stacks, [stackName]: { branches } } };
|
|
307
|
-
});
|
|
308
|
-
}),
|
|
309
|
-
|
|
310
|
-
createStack: Effect.fn("test.createStack")(function* (name: string, branches: string[]) {
|
|
311
|
-
yield* Ref.update(ref, (d) => ({
|
|
312
|
-
...d,
|
|
313
|
-
stacks: { ...d.stacks, [name]: { branches } },
|
|
314
|
-
}));
|
|
315
|
-
}),
|
|
752
|
+
const ref = yield* Ref.make<CanonicalStackFile>(initial);
|
|
316
753
|
|
|
754
|
+
return makeStackService({
|
|
755
|
+
loadData: () => Ref.get(ref),
|
|
756
|
+
saveData: (next) => Ref.set(ref, normalizeStackFile(next)),
|
|
757
|
+
currentBranch: () => Effect.succeed(options?.currentBranch ?? "test-branch"),
|
|
317
758
|
detectTrunkCandidate: () =>
|
|
318
759
|
Effect.succeed(
|
|
319
760
|
options?.detectTrunkCandidate !== undefined
|
|
320
761
|
? options.detectTrunkCandidate
|
|
321
762
|
: Option.some(initial.trunk),
|
|
322
763
|
),
|
|
323
|
-
|
|
324
|
-
setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
|
|
325
|
-
};
|
|
764
|
+
});
|
|
326
765
|
}),
|
|
327
766
|
);
|
|
328
767
|
};
|