@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.
@@ -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
- 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
+ // 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 emptyStackFile: StackFile = { version: 1, trunk: "main", stacks: {} };
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<StackFile, StackError>;
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: (stackName: string, branch: string) => Effect.Effect<void, StackError>;
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, never, GitService> = Layer.effect(
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
- const stackFilePath = Effect.fn("stackFilePath")(function* () {
52
- const gitDir = yield* git
53
- .revParse("--absolute-git-dir")
54
- .pipe(
55
- Effect.mapError(
56
- (e) => new StackError({ message: `Not a git repository: ${e.message}` }),
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(StackFileSchema);
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(StackFileJson);
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
- 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))),
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<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
- }),
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
- getTrunk: () => Ref.get(ref).pipe(Effect.map((d) => d.trunk)),
324
- setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
325
- };
760
+ });
326
761
  }),
327
762
  );
328
763
  };