@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/services/Stack.ts
CHANGED
|
@@ -4,26 +4,609 @@ 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
|
+
// branches.length > 0 guaranteed by the early return above
|
|
256
|
+
const root = branches[0] as string;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
...data,
|
|
260
|
+
stacks: {
|
|
261
|
+
...data.stacks,
|
|
262
|
+
[stackName]: { root },
|
|
263
|
+
},
|
|
264
|
+
branches: nextBranches,
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const renameStackRefs = (
|
|
269
|
+
data: CanonicalStackFile,
|
|
270
|
+
oldName: string,
|
|
271
|
+
newName: string,
|
|
272
|
+
): CanonicalStackFile => {
|
|
273
|
+
const stackRecord = data.stacks[oldName];
|
|
274
|
+
if (stackRecord === undefined) return data;
|
|
275
|
+
|
|
276
|
+
const { [oldName]: _, ...restStacks } = data.stacks;
|
|
277
|
+
const branches: Record<string, { stack: string; parent: string | null }> = {};
|
|
278
|
+
|
|
279
|
+
for (const [branch, record] of Object.entries(data.branches)) {
|
|
280
|
+
branches[branch] = {
|
|
281
|
+
...record,
|
|
282
|
+
stack: record.stack === oldName ? newName : record.stack,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
...data,
|
|
288
|
+
stacks: {
|
|
289
|
+
...restStacks,
|
|
290
|
+
[newName]: stackRecord,
|
|
291
|
+
},
|
|
292
|
+
branches,
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
interface StackServiceFactoryOptions {
|
|
297
|
+
readonly loadData: () => Effect.Effect<CanonicalStackFile, StackError>;
|
|
298
|
+
readonly saveData: (data: CanonicalStackFile) => Effect.Effect<void, StackError>;
|
|
299
|
+
readonly currentBranch: () => Effect.Effect<string, StackError | GitError>;
|
|
300
|
+
readonly detectTrunkCandidate: () => Effect.Effect<Option.Option<string>, never>;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const makeStackService = ({
|
|
304
|
+
loadData,
|
|
305
|
+
saveData,
|
|
306
|
+
currentBranch,
|
|
307
|
+
detectTrunkCandidate,
|
|
308
|
+
}: StackServiceFactoryOptions): ServiceMap.Service.Shape<typeof StackService> => {
|
|
309
|
+
const snapshot = () => loadData().pipe(Effect.flatMap(projectStacks));
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
load: () => loadData(),
|
|
313
|
+
save: (data) => saveData(normalizeStackFile(data)),
|
|
314
|
+
|
|
315
|
+
listStacks: () =>
|
|
316
|
+
snapshot().pipe(
|
|
317
|
+
Effect.map((state) =>
|
|
318
|
+
Object.entries(state.stacks).map(([name, stack]) => ({ name, stack })),
|
|
319
|
+
),
|
|
320
|
+
),
|
|
321
|
+
|
|
322
|
+
getStack: (name) => snapshot().pipe(Effect.map((state) => state.stacks[name] ?? null)),
|
|
323
|
+
|
|
324
|
+
trackedBranches: () => loadData().pipe(Effect.map((data) => Object.keys(data.branches).sort())),
|
|
325
|
+
|
|
326
|
+
findBranchStack: (branch) =>
|
|
327
|
+
snapshot().pipe(Effect.map((state) => state.branchToStack.get(branch) ?? null)),
|
|
328
|
+
|
|
329
|
+
detectTrunkCandidate: () => detectTrunkCandidate(),
|
|
330
|
+
|
|
331
|
+
currentStack: Effect.fn("StackService.currentStack")(function* () {
|
|
332
|
+
const branch = yield* currentBranch();
|
|
333
|
+
const state = yield* snapshot();
|
|
334
|
+
return state.branchToStack.get(branch) ?? null;
|
|
335
|
+
}),
|
|
336
|
+
|
|
337
|
+
addBranch: Effect.fn("StackService.addBranch")(function* (
|
|
338
|
+
stackName: string,
|
|
339
|
+
branch: string,
|
|
340
|
+
after?: string,
|
|
341
|
+
) {
|
|
342
|
+
const data = yield* loadData();
|
|
343
|
+
const state = yield* projectStacks(data);
|
|
344
|
+
|
|
345
|
+
if (data.branches[branch] !== undefined) {
|
|
346
|
+
const existing = state.branchToStack.get(branch);
|
|
347
|
+
return yield* new StackError({
|
|
348
|
+
message: `Branch "${branch}" is already in stack "${existing?.name ?? data.branches[branch]?.stack ?? "unknown"}"`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const stack = state.stacks[stackName];
|
|
353
|
+
if (stack === undefined) {
|
|
354
|
+
return yield* new StackError({ message: `Stack "${stackName}" not found` });
|
|
355
|
+
}
|
|
19
356
|
|
|
20
|
-
const
|
|
357
|
+
const branches = [...stack.branches];
|
|
358
|
+
if (after !== undefined) {
|
|
359
|
+
const idx = branches.indexOf(after);
|
|
360
|
+
if (idx === -1) {
|
|
361
|
+
return yield* new StackError({
|
|
362
|
+
message: `Branch "${after}" not in stack "${stackName}"`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
branches.splice(idx + 1, 0, branch);
|
|
366
|
+
} else {
|
|
367
|
+
branches.push(branch);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const next = rewriteStackBranches(data, stackName, branches);
|
|
371
|
+
yield* saveData({
|
|
372
|
+
...next,
|
|
373
|
+
mergedBranches: next.mergedBranches.filter((name) => name !== branch),
|
|
374
|
+
});
|
|
375
|
+
}),
|
|
376
|
+
|
|
377
|
+
removeBranch: Effect.fn("StackService.removeBranch")(function* (branch: string) {
|
|
378
|
+
const data = yield* loadData();
|
|
379
|
+
const state = yield* projectStacks(data);
|
|
380
|
+
const resolved = state.branchToStack.get(branch);
|
|
381
|
+
if (resolved === undefined) {
|
|
382
|
+
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const stackName = resolved.name;
|
|
386
|
+
const stack = resolved.stack;
|
|
387
|
+
const remaining = stack.branches.filter((name) => name !== branch);
|
|
388
|
+
const { [branch]: _, ...remainingBranches } = data.branches;
|
|
389
|
+
|
|
390
|
+
if (remaining.length === 0) {
|
|
391
|
+
const { [stackName]: __, ...restStacks } = data.stacks;
|
|
392
|
+
yield* saveData({
|
|
393
|
+
...data,
|
|
394
|
+
stacks: restStacks,
|
|
395
|
+
branches: remainingBranches,
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let next: CanonicalStackFile = {
|
|
401
|
+
...data,
|
|
402
|
+
branches: remainingBranches,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
if (branch === stack.root && stackName === stack.root) {
|
|
406
|
+
const [nextRoot] = remaining;
|
|
407
|
+
if (nextRoot !== undefined) {
|
|
408
|
+
next = renameStackRefs(next, stackName, nextRoot);
|
|
409
|
+
yield* saveData(rewriteStackBranches(next, nextRoot, remaining));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
yield* saveData(rewriteStackBranches(next, stackName, remaining));
|
|
415
|
+
}),
|
|
416
|
+
|
|
417
|
+
createStack: Effect.fn("StackService.createStack")(function* (
|
|
418
|
+
name: string,
|
|
419
|
+
branches: string[],
|
|
420
|
+
) {
|
|
421
|
+
if (branches.length === 0) {
|
|
422
|
+
return yield* new StackError({
|
|
423
|
+
message: `Stack "${name}" must contain at least one branch`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const data = yield* loadData();
|
|
428
|
+
if (data.stacks[name] !== undefined) {
|
|
429
|
+
return yield* new StackError({ message: `Stack "${name}" already exists` });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
for (const branch of branches) {
|
|
433
|
+
const existing = data.branches[branch];
|
|
434
|
+
if (existing !== undefined) {
|
|
435
|
+
return yield* new StackError({
|
|
436
|
+
message: `Branch "${branch}" is already in stack "${existing.stack}"`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const next = rewriteStackBranches(
|
|
442
|
+
{
|
|
443
|
+
...data,
|
|
444
|
+
stacks: {
|
|
445
|
+
...data.stacks,
|
|
446
|
+
[name]: { root: branches[0] ?? name },
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
name,
|
|
450
|
+
branches,
|
|
451
|
+
);
|
|
452
|
+
yield* saveData({
|
|
453
|
+
...next,
|
|
454
|
+
mergedBranches: next.mergedBranches.filter((branch) => !branches.includes(branch)),
|
|
455
|
+
});
|
|
456
|
+
}),
|
|
457
|
+
|
|
458
|
+
renameStack: Effect.fn("StackService.renameStack")(function* (
|
|
459
|
+
oldName: string,
|
|
460
|
+
newName: string,
|
|
461
|
+
) {
|
|
462
|
+
const data = yield* loadData();
|
|
463
|
+
if (data.stacks[oldName] === undefined) {
|
|
464
|
+
return yield* new StackError({ message: `Stack "${oldName}" not found` });
|
|
465
|
+
}
|
|
466
|
+
if (data.stacks[newName] !== undefined) {
|
|
467
|
+
return yield* new StackError({ message: `Stack "${newName}" already exists` });
|
|
468
|
+
}
|
|
469
|
+
yield* saveData(renameStackRefs(data, oldName, newName));
|
|
470
|
+
}),
|
|
471
|
+
|
|
472
|
+
splitStack: Effect.fn("StackService.splitStack")(function* (branch: string) {
|
|
473
|
+
const data = yield* loadData();
|
|
474
|
+
const state = yield* projectStacks(data);
|
|
475
|
+
const resolved = state.branchToStack.get(branch);
|
|
476
|
+
if (resolved === undefined) {
|
|
477
|
+
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const stackName = resolved.name;
|
|
481
|
+
const branches = [...resolved.stack.branches];
|
|
482
|
+
const splitIndex = branches.indexOf(branch);
|
|
483
|
+
if (splitIndex <= 0) {
|
|
484
|
+
return yield* new StackError({
|
|
485
|
+
message: `Branch "${branch}" is at the bottom of the stack — nothing to split`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (data.stacks[branch] !== undefined) {
|
|
490
|
+
return yield* new StackError({
|
|
491
|
+
message: `Stack "${branch}" already exists — choose a different split point or rename it first`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const original = branches.slice(0, splitIndex);
|
|
496
|
+
const created = branches.slice(splitIndex);
|
|
497
|
+
let next = rewriteStackBranches(data, stackName, original);
|
|
498
|
+
next = rewriteStackBranches(
|
|
499
|
+
{
|
|
500
|
+
...next,
|
|
501
|
+
stacks: {
|
|
502
|
+
...next.stacks,
|
|
503
|
+
[branch]: { root: branch },
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
branch,
|
|
507
|
+
created,
|
|
508
|
+
);
|
|
509
|
+
yield* saveData(next);
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
original: { name: stackName, branches: original },
|
|
513
|
+
created: { name: branch, branches: created },
|
|
514
|
+
} satisfies SplitResult;
|
|
515
|
+
}),
|
|
516
|
+
|
|
517
|
+
reorderBranch: Effect.fn("StackService.reorderBranch")(function* (
|
|
518
|
+
branch: string,
|
|
519
|
+
position: { before?: string; after?: string },
|
|
520
|
+
) {
|
|
521
|
+
const data = yield* loadData();
|
|
522
|
+
const state = yield* projectStacks(data);
|
|
523
|
+
const resolved = state.branchToStack.get(branch);
|
|
524
|
+
if (resolved === undefined) {
|
|
525
|
+
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const stackName = resolved.name;
|
|
529
|
+
const branches = [...resolved.stack.branches];
|
|
530
|
+
const currentIdx = branches.indexOf(branch);
|
|
531
|
+
if (currentIdx === -1) {
|
|
532
|
+
return yield* new StackError({
|
|
533
|
+
message: `Branch "${branch}" not found in stack "${stackName}"`,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const target = position.before ?? position.after;
|
|
538
|
+
if (target === undefined) {
|
|
539
|
+
return yield* new StackError({
|
|
540
|
+
message: "Specify --before or --after to indicate target position",
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const targetIdx = branches.indexOf(target);
|
|
545
|
+
if (targetIdx === -1) {
|
|
546
|
+
return yield* new StackError({
|
|
547
|
+
message: `Branch "${target}" not found in stack "${stackName}"`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
branches.splice(currentIdx, 1);
|
|
552
|
+
const nextTargetIdx = branches.indexOf(target);
|
|
553
|
+
branches.splice(position.before !== undefined ? nextTargetIdx : nextTargetIdx + 1, 0, branch);
|
|
554
|
+
|
|
555
|
+
const next = rewriteStackBranches(data, stackName, branches);
|
|
556
|
+
yield* saveData(next);
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
name: stackName,
|
|
560
|
+
stack: { root: branches[0] ?? resolved.stack.root, branches },
|
|
561
|
+
};
|
|
562
|
+
}),
|
|
563
|
+
|
|
564
|
+
markMergedBranches: Effect.fn("StackService.markMergedBranches")(function* (
|
|
565
|
+
branches: readonly string[],
|
|
566
|
+
) {
|
|
567
|
+
if (branches.length === 0) return;
|
|
568
|
+
const data = yield* loadData();
|
|
569
|
+
yield* saveData({
|
|
570
|
+
...data,
|
|
571
|
+
mergedBranches: sortUnique([...data.mergedBranches, ...branches]),
|
|
572
|
+
});
|
|
573
|
+
}),
|
|
574
|
+
|
|
575
|
+
unmarkMergedBranches: Effect.fn("StackService.unmarkMergedBranches")(function* (
|
|
576
|
+
branches: readonly string[],
|
|
577
|
+
) {
|
|
578
|
+
if (branches.length === 0) return;
|
|
579
|
+
const branchSet = new Set(branches);
|
|
580
|
+
const data = yield* loadData();
|
|
581
|
+
yield* saveData({
|
|
582
|
+
...data,
|
|
583
|
+
mergedBranches: data.mergedBranches.filter((branch) => !branchSet.has(branch)),
|
|
584
|
+
});
|
|
585
|
+
}),
|
|
586
|
+
|
|
587
|
+
getTrunk: Effect.fn("StackService.getTrunk")(function* () {
|
|
588
|
+
const data = yield* loadData();
|
|
589
|
+
return data.trunk;
|
|
590
|
+
}),
|
|
591
|
+
|
|
592
|
+
setTrunk: Effect.fn("StackService.setTrunk")(function* (name: string) {
|
|
593
|
+
const data = yield* loadData();
|
|
594
|
+
yield* saveData({ ...data, trunk: name });
|
|
595
|
+
}),
|
|
596
|
+
};
|
|
597
|
+
};
|
|
21
598
|
|
|
22
599
|
export class StackService extends ServiceMap.Service<
|
|
23
600
|
StackService,
|
|
24
601
|
{
|
|
25
|
-
readonly load: () => Effect.Effect<
|
|
602
|
+
readonly load: () => Effect.Effect<CanonicalStackFile, StackError>;
|
|
26
603
|
readonly save: (data: StackFile) => Effect.Effect<void, StackError>;
|
|
604
|
+
readonly listStacks: () => Effect.Effect<
|
|
605
|
+
ReadonlyArray<{ readonly name: string; readonly stack: Stack }>,
|
|
606
|
+
StackError
|
|
607
|
+
>;
|
|
608
|
+
readonly getStack: (name: string) => Effect.Effect<Stack | null, StackError>;
|
|
609
|
+
readonly trackedBranches: () => Effect.Effect<readonly string[], StackError>;
|
|
27
610
|
readonly currentStack: () => Effect.Effect<
|
|
28
611
|
{ name: string; stack: Stack } | null,
|
|
29
612
|
StackError | GitError
|
|
@@ -33,8 +616,16 @@ export class StackService extends ServiceMap.Service<
|
|
|
33
616
|
branch: string,
|
|
34
617
|
after?: string,
|
|
35
618
|
) => Effect.Effect<void, StackError>;
|
|
36
|
-
readonly removeBranch: (
|
|
619
|
+
readonly removeBranch: (branch: string) => Effect.Effect<void, StackError>;
|
|
37
620
|
readonly createStack: (name: string, branches: string[]) => Effect.Effect<void, StackError>;
|
|
621
|
+
readonly renameStack: (oldName: string, newName: string) => Effect.Effect<void, StackError>;
|
|
622
|
+
readonly splitStack: (branch: string) => Effect.Effect<SplitResult, StackError>;
|
|
623
|
+
readonly reorderBranch: (
|
|
624
|
+
branch: string,
|
|
625
|
+
position: { before?: string; after?: string },
|
|
626
|
+
) => Effect.Effect<{ name: string; stack: Stack }, StackError>;
|
|
627
|
+
readonly markMergedBranches: (branches: readonly string[]) => Effect.Effect<void, StackError>;
|
|
628
|
+
readonly unmarkMergedBranches: (branches: readonly string[]) => Effect.Effect<void, StackError>;
|
|
38
629
|
readonly findBranchStack: (
|
|
39
630
|
branch: string,
|
|
40
631
|
) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
|
|
@@ -43,25 +634,26 @@ export class StackService extends ServiceMap.Service<
|
|
|
43
634
|
readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
|
|
44
635
|
}
|
|
45
636
|
>()("@cvr/stacked/services/Stack/StackService") {
|
|
46
|
-
static layer: Layer.Layer<StackService,
|
|
637
|
+
static layer: Layer.Layer<StackService, StackError, GitService> = Layer.effect(
|
|
47
638
|
StackService,
|
|
48
639
|
Effect.gen(function* () {
|
|
49
640
|
const git = yield* GitService;
|
|
50
641
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return `${gitDir}/stacked.json`;
|
|
60
|
-
});
|
|
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);
|
|
61
650
|
|
|
62
|
-
const StackFileJson = Schema.fromJsonString(
|
|
651
|
+
const StackFileJson = Schema.fromJsonString(
|
|
652
|
+
Schema.Union([StackFileV1Schema, StackFileV2Schema]),
|
|
653
|
+
);
|
|
654
|
+
const PersistStackFileJson = Schema.fromJsonString(StackFilePersistSchema);
|
|
63
655
|
const decodeStackFile = Schema.decodeUnknownEffect(StackFileJson);
|
|
64
|
-
const encodeStackFile = Schema.encodeEffect(
|
|
656
|
+
const encodeStackFile = Schema.encodeEffect(PersistStackFileJson);
|
|
65
657
|
|
|
66
658
|
const detectTrunkCandidate = Effect.fn("StackService.detectTrunkCandidate")(function* () {
|
|
67
659
|
const remoteDefault = yield* git.remoteDefaultBranch("origin");
|
|
@@ -102,6 +694,7 @@ export class StackService extends ServiceMap.Service<
|
|
|
102
694
|
catch: () => new StackError({ message: `Failed to read ${path}` }),
|
|
103
695
|
});
|
|
104
696
|
return yield* decodeStackFile(text).pipe(
|
|
697
|
+
Effect.map((data) => normalizeStackFile(data as StackFile)),
|
|
105
698
|
Effect.catchTag("SchemaError", (e) =>
|
|
106
699
|
Effect.gen(function* () {
|
|
107
700
|
const backupPath = `${path}.backup`;
|
|
@@ -122,7 +715,7 @@ export class StackService extends ServiceMap.Service<
|
|
|
122
715
|
const save = Effect.fn("StackService.save")(function* (data: StackFile) {
|
|
123
716
|
const path = yield* stackFilePath();
|
|
124
717
|
const tmpPath = `${path}.tmp`;
|
|
125
|
-
const text = yield* encodeStackFile(data).pipe(
|
|
718
|
+
const text = yield* encodeStackFile(normalizeStackFile(data)).pipe(
|
|
126
719
|
Effect.mapError(() => new StackError({ message: `Failed to encode stack data` })),
|
|
127
720
|
);
|
|
128
721
|
yield* Effect.tryPromise({
|
|
@@ -135,108 +728,12 @@ export class StackService extends ServiceMap.Service<
|
|
|
135
728
|
});
|
|
136
729
|
});
|
|
137
730
|
|
|
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))),
|
|
731
|
+
return makeStackService({
|
|
732
|
+
loadData: () => load(),
|
|
733
|
+
saveData: (data) => save(data),
|
|
734
|
+
currentBranch: () => git.currentBranch(),
|
|
153
735
|
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
|
-
};
|
|
736
|
+
});
|
|
240
737
|
}),
|
|
241
738
|
);
|
|
242
739
|
|
|
@@ -244,85 +741,23 @@ export class StackService extends ServiceMap.Service<
|
|
|
244
741
|
data?: StackFile,
|
|
245
742
|
options?: { currentBranch?: string; detectTrunkCandidate?: Option.Option<string> },
|
|
246
743
|
) => {
|
|
247
|
-
const initial = data ?? emptyStackFile;
|
|
744
|
+
const initial = normalizeStackFile(data ?? emptyStackFile);
|
|
248
745
|
return Layer.effect(
|
|
249
746
|
StackService,
|
|
250
747
|
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
|
-
}),
|
|
748
|
+
const ref = yield* Ref.make<CanonicalStackFile>(initial);
|
|
316
749
|
|
|
750
|
+
return makeStackService({
|
|
751
|
+
loadData: () => Ref.get(ref),
|
|
752
|
+
saveData: (next) => Ref.set(ref, normalizeStackFile(next)),
|
|
753
|
+
currentBranch: () => Effect.succeed(options?.currentBranch ?? "test-branch"),
|
|
317
754
|
detectTrunkCandidate: () =>
|
|
318
755
|
Effect.succeed(
|
|
319
756
|
options?.detectTrunkCandidate !== undefined
|
|
320
757
|
? options.detectTrunkCandidate
|
|
321
758
|
: Option.some(initial.trunk),
|
|
322
759
|
),
|
|
323
|
-
|
|
324
|
-
setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
|
|
325
|
-
};
|
|
760
|
+
});
|
|
326
761
|
}),
|
|
327
762
|
);
|
|
328
763
|
};
|