@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.
@@ -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
- export const StackSchema = Schema.Struct({
7
+ const StackV1Schema = Schema.Struct({
8
8
  branches: Schema.Array(Schema.String),
9
9
  });
10
10
 
11
- export const StackFileSchema = Schema.Struct({
11
+ const StackFileV1Schema = Schema.Struct({
12
12
  version: Schema.Literal(1),
13
13
  trunk: Schema.String,
14
- stacks: Schema.Record(Schema.String, StackSchema),
14
+ stacks: Schema.Record(Schema.String, StackV1Schema),
15
+ mergedBranches: Schema.optional(Schema.Array(Schema.String)),
15
16
  });
16
17
 
17
- export type Stack = typeof StackSchema.Type;
18
- export type StackFile = typeof StackFileSchema.Type;
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 emptyStackFile: StackFile = { version: 1, trunk: "main", stacks: {} };
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<StackFile, StackError>;
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: (stackName: string, branch: string) => Effect.Effect<void, StackError>;
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(StackFileSchema);
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(StackFileJson);
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
- const findBranchStack = (data: StackFile, branch: string) => {
139
- for (const [name, stack] of Object.entries(data.stacks)) {
140
- if (stack.branches.includes(branch)) {
141
- return { name, stack };
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<StackFile>(initial);
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
- getTrunk: () => Ref.get(ref).pipe(Effect.map((d) => d.trunk)),
324
- setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
325
- };
764
+ });
326
765
  }),
327
766
  );
328
767
  };