import { Context, Effect, Layer, Option, Ref, Schema } from "effect"; import type { Crypto, FileSystem, Path } from "effect"; import type { GitError } from "../errors/index.js"; import { ErrorCode, StackError } from "../errors/index.js"; import { GitService } from "./Git.js"; import { RepositoryStore } from "./RepositoryStore.js"; const StackV1Schema = Schema.Struct({ branches: Schema.Array(Schema.String), }); const StackFileV1Schema = Schema.Struct({ version: Schema.Literal(1), trunk: Schema.String, stacks: Schema.Record(Schema.String, StackV1Schema), mergedBranches: Schema.optional(Schema.Array(Schema.String)), }); const StackRecordSchema = Schema.Struct({ root: Schema.String, }); const BranchRecordSchema = Schema.Struct({ stack: Schema.String, parent: Schema.NullOr(Schema.String), syncedOnto: Schema.optional(Schema.NullOr(Schema.String)), }); const StackFileV2Schema = Schema.Struct({ version: Schema.Literal(2), trunk: Schema.String, stacks: Schema.Record(Schema.String, StackRecordSchema), branches: Schema.Record(Schema.String, BranchRecordSchema), mergedBranches: Schema.optional(Schema.Array(Schema.String)), }); const StackFilePersistSchema = Schema.Struct({ version: Schema.Literal(2), trunk: Schema.String, stacks: Schema.Record(Schema.String, StackRecordSchema), branches: Schema.Record( Schema.String, Schema.Struct({ stack: Schema.String, parent: Schema.NullOr(Schema.String), }), ), mergedBranches: Schema.Array(Schema.String), }); const CheckoutStateSchema = Schema.Struct({ version: Schema.Literal(1), syncedOnto: Schema.Record(Schema.String, Schema.String), }); type StackFileV1 = typeof StackFileV1Schema.Type; type StackFileV2Input = typeof StackFileV2Schema.Type; export type StackFile = StackFileV1 | StackFileV2Input; export interface Stack { readonly root: string; readonly branches: readonly string[]; } export interface LineageBranch { readonly name: string; readonly parent: string; readonly activeParent: string; readonly merged: boolean; } export interface StackLineage { readonly name: string; readonly trunk: string; readonly branches: readonly LineageBranch[]; } interface CanonicalStackFile { readonly version: 2; readonly trunk: string; readonly stacks: Readonly>; readonly branches: Readonly< Record< string, { readonly stack: string; readonly parent: string | null; readonly syncedOnto?: string | null | undefined; } > >; readonly mergedBranches: readonly string[]; } interface StackSnapshot { readonly stacks: Readonly>; readonly branchToStack: ReadonlyMap; } interface SplitResult { readonly original: { readonly name: string; readonly branches: readonly string[] }; readonly created: { readonly name: string; readonly branches: readonly string[] }; } export interface ReparentResult { readonly branch: string; readonly onto: string; readonly moved: readonly string[]; readonly source: { readonly name: string; readonly branches: readonly string[] } | null; readonly destination: { readonly name: string; readonly branches: readonly string[] }; readonly invalidatedSyncMarkers: readonly string[]; readonly changed: boolean; } const emptyStackFile: CanonicalStackFile = { version: 2, trunk: "main", stacks: {}, branches: {}, mergedBranches: [], }; const sortUnique = (values: readonly string[]) => [...new Set(values)].sort(); const migrateV1ToV2 = (input: StackFileV1): CanonicalStackFile => { const stacks: Record = {}; const branches: Record = {}; for (const [name, stack] of Object.entries(input.stacks)) { const [root] = stack.branches; if (root === undefined) continue; stacks[name] = { root }; for (let i = 0; i < stack.branches.length; i++) { const branch = stack.branches[i]; if (branch === undefined) continue; branches[branch] = { stack: name, parent: i === 0 ? null : (stack.branches[i - 1] ?? null), }; } } return { version: 2, trunk: input.trunk, stacks, branches, mergedBranches: sortUnique( (input.mergedBranches ?? []).filter((branch) => branches[branch] === undefined), ), }; }; const normalizeStackFile = (input: StackFile): CanonicalStackFile => { const migrated = input.version === 1 ? migrateV1ToV2(input) : input; const branches = { ...migrated.branches }; for (const [stackName, stack] of Object.entries(migrated.stacks)) { const root = branches[stack.root]; if (root?.stack === stackName && root.parent === stack.root) { branches[stack.root] = { stack: root.stack, parent: null }; } } return { version: 2, trunk: migrated.trunk, stacks: migrated.stacks, branches, mergedBranches: sortUnique(migrated.mergedBranches ?? []), }; }; const withoutSyncState = (data: CanonicalStackFile): CanonicalStackFile => ({ ...data, branches: Object.fromEntries( Object.entries(data.branches).map(([branch, record]) => [ branch, { stack: record.stack, parent: record.parent }, ]), ), }); const checkoutState = (data: CanonicalStackFile) => ({ version: 1 as const, syncedOnto: Object.fromEntries( Object.entries(data.branches).flatMap(([branch, record]) => record.syncedOnto == null ? [] : [[branch, record.syncedOnto]], ), ), }); const applyCheckoutState = ( data: CanonicalStackFile, state: typeof CheckoutStateSchema.Type, ): CanonicalStackFile => ({ ...data, branches: Object.fromEntries( Object.entries(data.branches).map(([branch, record]) => { const syncedOnto = state.syncedOnto[branch]; return [branch, syncedOnto === undefined ? record : { ...record, syncedOnto }]; }), ), }); const projectStacks = (data: CanonicalStackFile): Effect.Effect => Effect.gen(function* () { const branchesByStack = new Map(); for (const [branch, record] of Object.entries(data.branches)) { if (data.stacks[record.stack] === undefined) { return yield* new StackError({ message: `Branch "${branch}" points at missing stack "${record.stack}". Run 'stacked doctor --fix'.`, }); } const existing = branchesByStack.get(record.stack) ?? []; existing.push(branch); branchesByStack.set(record.stack, existing); } const stacks: Record = {}; const branchToStack = new Map(); for (const [name, stackRecord] of Object.entries(data.stacks)) { const stackBranches = branchesByStack.get(name) ?? []; if (stackBranches.length === 0) { return yield* new StackError({ message: `Stack "${name}" has no branches. Run 'stacked doctor --fix'.`, }); } const rootRecord = data.branches[stackRecord.root]; if (rootRecord === undefined || rootRecord.stack !== name || rootRecord.parent !== null) { return yield* new StackError({ message: `Stack "${name}" has invalid root "${stackRecord.root}". Run 'stacked doctor --fix'.`, }); } const childrenByParent = new Map(); for (const branch of stackBranches) { const record = data.branches[branch]; if (record === undefined) continue; if (record.parent !== null) { const parent = data.branches[record.parent]; if (parent === undefined || parent.stack !== name) { return yield* new StackError({ message: `Branch "${branch}" points at invalid parent "${record.parent}". Run 'stacked doctor --fix'.`, }); } } const children = childrenByParent.get(record.parent) ?? []; children.push(branch); childrenByParent.set(record.parent, children); } const rootChildren = childrenByParent.get(null) ?? []; if (rootChildren.length !== 1 || rootChildren[0] !== stackRecord.root) { return yield* new StackError({ message: `Stack "${name}" has an invalid root chain. Run 'stacked doctor --fix'.`, }); } const ordered: string[] = []; const visited = new Set(); let current: string | undefined = stackRecord.root; while (current !== undefined) { if (visited.has(current)) { return yield* new StackError({ message: `Stack "${name}" contains a cycle at "${current}". Run 'stacked doctor --fix'.`, }); } visited.add(current); ordered.push(current); const children: string[] = childrenByParent.get(current) ?? []; if (children.length > 1) { return yield* new StackError({ message: `Stack "${name}" forks at "${current}". Run 'stacked doctor --fix'.`, }); } current = children[0]; } if (ordered.length !== stackBranches.length) { return yield* new StackError({ message: `Stack "${name}" is disconnected. Run 'stacked doctor --fix'.`, }); } const stack: Stack = { root: stackRecord.root, branches: ordered }; stacks[name] = stack; for (const branch of ordered) { branchToStack.set(branch, { name, stack }); } } return { stacks, branchToStack }; }); const rewriteStackBranches = ( data: CanonicalStackFile, stackName: string, branches: readonly string[], previous: CanonicalStackFile = data, ): CanonicalStackFile => { const nextBranches: Record< string, { stack: string; parent: string | null; syncedOnto?: string | null } > = { ...data.branches, }; for (const [branch, record] of Object.entries(nextBranches)) { if (record.stack === stackName) { delete nextBranches[branch]; } } for (let i = 0; i < branches.length; i++) { const branch = branches[i]; if (branch === undefined) continue; const existing = previous.branches[branch]; const parent = i === 0 ? null : (branches[i - 1] ?? null); nextBranches[branch] = { stack: stackName, parent, ...(existing?.parent === parent && existing.syncedOnto != null ? { syncedOnto: existing.syncedOnto } : {}), }; } if (branches.length === 0) { const { [stackName]: _, ...restStacks } = data.stacks; return { ...data, stacks: restStacks, branches: nextBranches, }; } // branches.length > 0 guaranteed by the early return above const root = branches[0] as string; return { ...data, stacks: { ...data.stacks, [stackName]: { root }, }, branches: nextBranches, }; }; const projectLineage = ( data: CanonicalStackFile, name: string, stack: Stack, includeMerged: boolean, ): StackLineage => { const merged = new Set(data.mergedBranches); const branches: LineageBranch[] = []; let activeParent = data.trunk; for (let index = 0; index < stack.branches.length; index++) { const branch = stack.branches[index]; if (branch === undefined) continue; const isMerged = merged.has(branch); branches.push({ name: branch, parent: index === 0 ? data.trunk : (stack.branches[index - 1] ?? data.trunk), activeParent, merged: isMerged, }); if (includeMerged || !isMerged) activeParent = branch; } return { name, trunk: data.trunk, branches }; }; const renameStackRefs = ( data: CanonicalStackFile, oldName: string, newName: string, ): CanonicalStackFile => { const stackRecord = data.stacks[oldName]; if (stackRecord === undefined) return data; const { [oldName]: _, ...restStacks } = data.stacks; const branches: Record< string, { stack: string; parent: string | null; syncedOnto?: string | null } > = {}; for (const [branch, record] of Object.entries(data.branches)) { branches[branch] = { ...record, stack: record.stack === oldName ? newName : record.stack, }; } return { ...data, stacks: { ...restStacks, [newName]: stackRecord, }, branches, }; }; interface StackServiceFactoryOptions { readonly loadData: () => Effect.Effect; readonly saveData: (data: CanonicalStackFile) => Effect.Effect; readonly currentBranch: () => Effect.Effect; readonly detectTrunkCandidate: () => Effect.Effect, never>; } const makeStackService = ({ loadData, saveData, currentBranch, detectTrunkCandidate, }: StackServiceFactoryOptions): Context.Service.Shape => { const snapshot = () => loadData().pipe(Effect.flatMap(projectStacks)); const getLineage = Effect.fn("StackService.getLineage")(function* ( branch: string, options?: { readonly includeMerged?: boolean; }, ) { const data = yield* loadData(); const state = yield* projectStacks(data); const resolved = state.branchToStack.get(branch); if (resolved === undefined) return null; return projectLineage(data, resolved.name, resolved.stack, options?.includeMerged ?? false); }); const currentLineage = Effect.fn("StackService.currentLineage")(function* (options?: { readonly includeMerged?: boolean; }) { const branch = yield* currentBranch(); return yield* getLineage(branch, options); }); return { load: () => loadData(), save: (data) => saveData(normalizeStackFile(data)), listStacks: () => snapshot().pipe( Effect.map((state) => Object.entries(state.stacks).map(([name, stack]) => ({ name, stack })), ), ), getStack: (name) => snapshot().pipe(Effect.map((state) => state.stacks[name] ?? null)), trackedBranches: () => loadData().pipe(Effect.map((data) => Object.keys(data.branches).sort())), findBranchStack: (branch) => snapshot().pipe(Effect.map((state) => state.branchToStack.get(branch) ?? null)), detectTrunkCandidate: () => detectTrunkCandidate(), currentStack: Effect.fn("StackService.currentStack")(function* () { const branch = yield* currentBranch(); const state = yield* snapshot(); return state.branchToStack.get(branch) ?? null; }), currentLineage, getLineage, addBranch: Effect.fn("StackService.addBranch")(function* ( stackName: string, branch: string, after?: string, ) { const data = yield* loadData(); const state = yield* projectStacks(data); if (data.branches[branch] !== undefined) { const existing = state.branchToStack.get(branch); return yield* new StackError({ message: `Branch "${branch}" is already in stack "${existing?.name ?? data.branches[branch]?.stack ?? "unknown"}"`, }); } const stack = state.stacks[stackName]; if (stack === undefined) { return yield* new StackError({ message: `Stack "${stackName}" not found` }); } const branches = [...stack.branches]; if (after !== undefined) { const idx = branches.indexOf(after); if (idx === -1) { return yield* new StackError({ message: `Branch "${after}" not in stack "${stackName}"`, }); } branches.splice(idx + 1, 0, branch); } else { branches.push(branch); } const next = rewriteStackBranches(data, stackName, branches); yield* saveData({ ...next, mergedBranches: next.mergedBranches.filter((name) => name !== branch), }); }), removeBranch: Effect.fn("StackService.removeBranch")(function* (branch: string) { const data = yield* loadData(); const state = yield* projectStacks(data); const resolved = state.branchToStack.get(branch); if (resolved === undefined) { return yield* new StackError({ message: `Branch "${branch}" not found in any stack` }); } const stackName = resolved.name; const stack = resolved.stack; const remaining = stack.branches.filter((name) => name !== branch); const { [branch]: _, ...remainingBranches } = data.branches; if (remaining.length === 0) { const { [stackName]: __, ...restStacks } = data.stacks; yield* saveData({ ...data, stacks: restStacks, branches: remainingBranches, }); return; } let next: CanonicalStackFile = { ...data, branches: remainingBranches, }; if (branch === stack.root && stackName === stack.root) { const [nextRoot] = remaining; if (nextRoot !== undefined) { next = renameStackRefs(next, stackName, nextRoot); yield* saveData(rewriteStackBranches(next, nextRoot, remaining)); return; } } yield* saveData(rewriteStackBranches(next, stackName, remaining)); }), createStack: Effect.fn("StackService.createStack")(function* ( name: string, branches: string[], ) { if (branches.length === 0) { return yield* new StackError({ message: `Stack "${name}" must contain at least one branch`, }); } const duplicate = branches.find((branch, index) => branches.indexOf(branch) !== index); if (duplicate !== undefined) { return yield* new StackError({ message: `Branch "${duplicate}" appears more than once in stack "${name}"`, }); } const data = yield* loadData(); if (data.stacks[name] !== undefined) { return yield* new StackError({ message: `Stack "${name}" already exists` }); } for (const branch of branches) { const existing = data.branches[branch]; if (existing !== undefined) { return yield* new StackError({ message: `Branch "${branch}" is already in stack "${existing.stack}"`, }); } } const next = rewriteStackBranches( { ...data, stacks: { ...data.stacks, [name]: { root: branches[0] ?? name }, }, }, name, branches, ); yield* saveData({ ...next, mergedBranches: next.mergedBranches.filter((branch) => !branches.includes(branch)), }); }), renameStack: Effect.fn("StackService.renameStack")(function* ( oldName: string, newName: string, ) { const data = yield* loadData(); if (data.stacks[oldName] === undefined) { return yield* new StackError({ message: `Stack "${oldName}" not found` }); } if (data.stacks[newName] !== undefined) { return yield* new StackError({ message: `Stack "${newName}" already exists` }); } yield* saveData(renameStackRefs(data, oldName, newName)); }), splitStack: Effect.fn("StackService.splitStack")(function* (branch: string) { const data = yield* loadData(); const state = yield* projectStacks(data); const resolved = state.branchToStack.get(branch); if (resolved === undefined) { return yield* new StackError({ message: `Branch "${branch}" not found in any stack` }); } const stackName = resolved.name; const branches = [...resolved.stack.branches]; const splitIndex = branches.indexOf(branch); if (splitIndex <= 0) { return yield* new StackError({ message: `Branch "${branch}" is at the bottom of the stack — nothing to split`, }); } if (data.stacks[branch] !== undefined) { return yield* new StackError({ message: `Stack "${branch}" already exists — choose a different split point or rename it first`, }); } const original = branches.slice(0, splitIndex); const created = branches.slice(splitIndex); let next = rewriteStackBranches(data, stackName, original); next = rewriteStackBranches( { ...next, stacks: { ...next.stacks, [branch]: { root: branch }, }, }, branch, created, data, ); yield* saveData(next); return { original: { name: stackName, branches: original }, created: { name: branch, branches: created }, } satisfies SplitResult; }), reparentBranch: Effect.fn("StackService.reparentBranch")(function* ( branch: string, onto: string, options?: { readonly dryRun?: boolean }, ) { const data = yield* loadData(); const state = yield* projectStacks(data); const source = state.branchToStack.get(branch); if (source === undefined) { return yield* new StackError({ code: ErrorCode.BRANCH_NOT_FOUND, message: `Branch "${branch}" not found in any stack`, }); } const sourceBranches = [...source.stack.branches]; const sourceIndex = sourceBranches.indexOf(branch); const moved = sourceBranches.slice(sourceIndex); const sourceRemaining = sourceBranches.slice(0, sourceIndex); const currentParent = data.branches[branch]?.parent ?? data.trunk; if (onto === branch || moved.includes(onto)) { return yield* new StackError({ code: ErrorCode.USAGE_ERROR, message: `Cannot reparent "${branch}" onto itself or one of its descendants`, }); } if (onto === currentParent) { return { branch, onto, moved, source: { name: source.name, branches: sourceBranches }, destination: { name: source.name, branches: sourceBranches }, invalidatedSyncMarkers: [], changed: false, } satisfies ReparentResult; } let next: CanonicalStackFile; let destinationName: string; let destinationBranches: readonly string[]; if (onto === data.trunk) { if ( data.stacks[branch] !== undefined && (source.name !== branch || sourceRemaining.length > 0) ) { return yield* new StackError({ code: ErrorCode.STACK_EXISTS, message: `Cannot create stack "${branch}" because that stack name already exists`, }); } next = rewriteStackBranches(data, source.name, sourceRemaining, data); next = rewriteStackBranches( { ...next, stacks: { ...next.stacks, [branch]: { root: branch } }, }, branch, moved, data, ); destinationName = branch; destinationBranches = moved; } else { const destination = state.branchToStack.get(onto); if (destination === undefined) { return yield* new StackError({ code: ErrorCode.BRANCH_NOT_FOUND, message: `Parent branch "${onto}" is neither trunk nor part of a stack`, }); } if (destination.name === source.name) { const targetIndex = sourceRemaining.indexOf(onto); const reordered = [...sourceRemaining]; reordered.splice(targetIndex + 1, 0, ...moved); next = rewriteStackBranches(data, source.name, reordered, data); destinationName = source.name; destinationBranches = reordered; } else { const targetIndex = destination.stack.branches.indexOf(onto); const inserted = [...destination.stack.branches]; inserted.splice(targetIndex + 1, 0, ...moved); next = rewriteStackBranches(data, source.name, sourceRemaining, data); next = rewriteStackBranches(next, destination.name, inserted, data); destinationName = destination.name; destinationBranches = inserted; } } const invalidatedSyncMarkers = Object.entries(next.branches).flatMap(([name, record]) => { const previous = data.branches[name]; return previous?.parent !== record.parent && previous?.syncedOnto != null ? [name] : []; }); if (options?.dryRun !== true) yield* saveData(next); const finalSourceBranches = source.name === destinationName ? destinationBranches : sourceRemaining; return { branch, onto, moved, source: finalSourceBranches.length === 0 ? null : { name: source.name, branches: finalSourceBranches }, destination: { name: destinationName, branches: destinationBranches }, invalidatedSyncMarkers, changed: true, } satisfies ReparentResult; }), reorderBranch: Effect.fn("StackService.reorderBranch")(function* ( branch: string, position: { before?: string; after?: string }, ) { const data = yield* loadData(); const state = yield* projectStacks(data); const resolved = state.branchToStack.get(branch); if (resolved === undefined) { return yield* new StackError({ message: `Branch "${branch}" not found in any stack` }); } const stackName = resolved.name; const branches = [...resolved.stack.branches]; const currentIdx = branches.indexOf(branch); if (currentIdx === -1) { return yield* new StackError({ message: `Branch "${branch}" not found in stack "${stackName}"`, }); } const target = position.before ?? position.after; if (target === undefined) { return yield* new StackError({ message: "Specify --before or --after to indicate target position", }); } const targetIdx = branches.indexOf(target); if (targetIdx === -1) { return yield* new StackError({ message: `Branch "${target}" not found in stack "${stackName}"`, }); } branches.splice(currentIdx, 1); const nextTargetIdx = branches.indexOf(target); branches.splice(position.before !== undefined ? nextTargetIdx : nextTargetIdx + 1, 0, branch); const next = rewriteStackBranches(data, stackName, branches); yield* saveData(next); return { name: stackName, stack: { root: branches[0] ?? resolved.stack.root, branches }, }; }), markMergedBranches: Effect.fn("StackService.markMergedBranches")(function* ( branches: readonly string[], ) { if (branches.length === 0) return; const data = yield* loadData(); yield* saveData({ ...data, mergedBranches: sortUnique([...data.mergedBranches, ...branches]), }); }), unmarkMergedBranches: Effect.fn("StackService.unmarkMergedBranches")(function* ( branches: readonly string[], ) { if (branches.length === 0) return; const branchSet = new Set(branches); const data = yield* loadData(); yield* saveData({ ...data, mergedBranches: data.mergedBranches.filter((branch) => !branchSet.has(branch)), }); }), getSyncedOnto: Effect.fn("StackService.getSyncedOnto")(function* (branch: string) { const data = yield* loadData(); const record = data.branches[branch]; if (record === undefined) return null; return record.syncedOnto ?? null; }), updateSyncedOnto: Effect.fn("StackService.updateSyncedOnto")(function* ( branch: string, oid: string | null, ) { const data = yield* loadData(); const record = data.branches[branch]; if (record === undefined) { return yield* new StackError({ message: `Branch "${branch}" not found in stack metadata`, }); } yield* saveData({ ...data, branches: { ...data.branches, [branch]: { ...record, syncedOnto: oid }, }, }); }), getTrunk: Effect.fn("StackService.getTrunk")(function* () { const data = yield* loadData(); return data.trunk; }), setTrunk: Effect.fn("StackService.setTrunk")(function* (name: string) { const data = yield* loadData(); yield* saveData({ ...data, trunk: name }); }), }; }; export class StackService extends Context.Service< StackService, { readonly load: () => Effect.Effect; readonly save: (data: StackFile) => Effect.Effect; readonly listStacks: () => Effect.Effect< ReadonlyArray<{ readonly name: string; readonly stack: Stack }>, StackError >; readonly getStack: (name: string) => Effect.Effect; readonly trackedBranches: () => Effect.Effect; readonly currentStack: () => Effect.Effect< { name: string; stack: Stack } | null, StackError | GitError >; readonly currentLineage: (options?: { readonly includeMerged?: boolean; }) => Effect.Effect; readonly getLineage: ( branch: string, options?: { readonly includeMerged?: boolean }, ) => Effect.Effect; readonly addBranch: ( stackName: string, branch: string, after?: string, ) => Effect.Effect; readonly removeBranch: (branch: string) => Effect.Effect; readonly createStack: (name: string, branches: string[]) => Effect.Effect; readonly renameStack: (oldName: string, newName: string) => Effect.Effect; readonly splitStack: (branch: string) => Effect.Effect; readonly reparentBranch: ( branch: string, onto: string, options?: { readonly dryRun?: boolean }, ) => Effect.Effect; readonly reorderBranch: ( branch: string, position: { before?: string; after?: string }, ) => Effect.Effect<{ name: string; stack: Stack }, StackError>; readonly markMergedBranches: (branches: readonly string[]) => Effect.Effect; readonly unmarkMergedBranches: (branches: readonly string[]) => Effect.Effect; readonly findBranchStack: ( branch: string, ) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>; readonly detectTrunkCandidate: () => Effect.Effect, never>; readonly getSyncedOnto: (branch: string) => Effect.Effect; readonly updateSyncedOnto: ( branch: string, oid: string | null, ) => Effect.Effect; readonly getTrunk: () => Effect.Effect; readonly setTrunk: (name: string) => Effect.Effect; } >()("@cvr/stacked/services/Stack/StackService") { static layerWithStore: Layer.Layer = Layer.effect( StackService, Effect.gen(function* () { const git = yield* GitService; const store = yield* RepositoryStore; const loadedGlobal = yield* Ref.make(Option.none()); const loadedCheckout = yield* Ref.make(Option.none()); const StackFileJson = Schema.fromJsonString( Schema.Union([StackFileV1Schema, StackFileV2Schema]), ); const PersistStackFileJson = Schema.fromJsonString(StackFilePersistSchema); const CheckoutStateJson = Schema.fromJsonString(CheckoutStateSchema); const decodeStackFile = Schema.decodeUnknownEffect(StackFileJson); const encodeStackFile = Schema.encodeEffect(PersistStackFileJson); const decodeCheckoutState = Schema.decodeUnknownEffect(CheckoutStateJson); const encodeCheckoutState = Schema.encodeEffect(CheckoutStateJson); const decode = Effect.fn("StackService.decodeStackFile")(function* ( text: string, path: string, ) { return yield* decodeStackFile(text).pipe( Effect.map((data) => normalizeStackFile(data as StackFile)), Effect.mapError( (error) => new StackError({ message: `Invalid stack metadata at ${path}: ${error}` }), ), ); }); const encodeGlobal = Effect.fn("StackService.encodeStackFile")(function* ( data: CanonicalStackFile, ) { return yield* encodeStackFile(withoutSyncState(data)).pipe( Effect.map((text) => `${text}\n`), Effect.mapError(() => new StackError({ message: "Failed to encode stack data" })), ); }); const encodeCheckout = Effect.fn("StackService.encodeCheckoutState")(function* ( data: CanonicalStackFile, ) { return yield* encodeCheckoutState(checkoutState(data)).pipe( Effect.map((text) => `${text}\n`), Effect.mapError(() => new StackError({ message: "Failed to encode checkout state" })), ); }); const save = Effect.fn("StackService.save")(function* (data: StackFile) { const normalized = normalizeStackFile(data); const globalText = yield* encodeGlobal(normalized); const checkoutText = yield* encodeCheckout(normalized); yield* store.savePair( globalText, yield* Ref.get(loadedGlobal), checkoutText, yield* Ref.get(loadedCheckout), ); yield* Ref.set(loadedGlobal, Option.some(globalText)); yield* Ref.set(loadedCheckout, Option.some(checkoutText)); }); const detectTrunkCandidate = Effect.fn("StackService.detectTrunkCandidate")(function* () { const remoteDefault = yield* git.remoteDefaultBranch("origin"); if (Option.isSome(remoteDefault)) { const exists = yield* git .branchExists(remoteDefault.value) .pipe(Effect.catchTag("GitError", () => Effect.succeed(false))); if (exists) return remoteDefault; } for (const candidate of ["main", "master", "develop"]) { const exists = yield* git .branchExists(candidate) .pipe(Effect.catchTag("GitError", () => Effect.succeed(false))); if (exists) return Option.some(candidate); } return Option.none(); }); const detectTrunk = Effect.fn("StackService.detectTrunk")(function* () { const candidate = yield* detectTrunkCandidate(); return Option.getOrElse(candidate, () => "main"); }); const load = Effect.fn("StackService.load")(function* () { const globalText = yield* store.loadGlobal(); const checkoutText = yield* store.loadCheckout(); yield* Ref.set(loadedGlobal, globalText); yield* Ref.set(loadedCheckout, checkoutText); const legacyFiles = yield* store.legacyFiles(); const globalData = yield* Option.match(globalText, { onNone: () => Effect.succeed(Option.none()), onSome: (text) => decode(text, store.location.stackFile).pipe(Effect.map(Option.some)), }); const legacyData = yield* Effect.forEach(legacyFiles, (file) => decode(file.text, file.path).pipe(Effect.map((data) => ({ file, data }))), ); const savedCheckout = yield* Option.match(checkoutText, { onNone: () => Effect.succeed({ version: 1 as const, syncedOnto: {} }), onSome: (text) => decodeCheckoutState(text).pipe( Effect.mapError( (error) => new StackError({ message: `Invalid checkout state at ${store.location.checkoutFile}: ${error}`, }), ), ), }); if (Option.isSome(globalData)) { const topology = withoutSyncState(globalData.value); const canonical = JSON.stringify(topology); const conflict = legacyData.find( ({ data }) => JSON.stringify(withoutSyncState(data)) !== canonical, ); if (conflict !== undefined) { return yield* new StackError({ message: `Legacy stack metadata at ${conflict.file.path} conflicts with shared metadata at ${store.location.stackFile}`, }); } const seedData = legacyData.find( ({ file }) => file.path === `${store.location.absoluteGitDirectory}/stacked.json`, )?.data ?? legacyData[0]?.data ?? globalData.value; const effectiveCheckout = Option.isNone(checkoutText) ? checkoutState(seedData) : savedCheckout; if (Option.isNone(checkoutText)) { const encoded = yield* encodeCheckout(seedData); yield* store.saveCheckout(encoded, checkoutText); yield* Ref.set(loadedCheckout, Option.some(encoded)); } if (legacyFiles.length > 0) yield* store.archiveLegacy(legacyFiles); return applyCheckoutState(topology, effectiveCheckout); } const [firstLegacy] = legacyData; if (firstLegacy !== undefined) { const canonical = JSON.stringify(withoutSyncState(firstLegacy.data)); const conflict = legacyData.find( ({ data }) => JSON.stringify(withoutSyncState(data)) !== canonical, ); if (conflict !== undefined) { return yield* new StackError({ message: `Legacy stack metadata conflicts between ${firstLegacy.file.path} and ${conflict.file.path}`, }); } const currentLegacy = legacyData.find( ({ file }) => file.path === `${store.location.absoluteGitDirectory}/stacked.json`, ) ?? firstLegacy; yield* save(currentLegacy.data); yield* store.archiveLegacy(legacyFiles); return currentLegacy.data; } const trunk = yield* detectTrunk(); return { ...emptyStackFile, trunk } satisfies CanonicalStackFile; }); return makeStackService({ loadData: () => load(), saveData: (data) => save(data), currentBranch: () => git.currentBranch(), detectTrunkCandidate: () => detectTrunkCandidate(), }); }), ); static layer: Layer.Layer< StackService, StackError, GitService | Crypto.Crypto | FileSystem.FileSystem | Path.Path > = StackService.layerWithStore.pipe(Layer.provide(RepositoryStore.layer)); static layerTest = ( data?: StackFile, options?: { currentBranch?: string; detectTrunkCandidate?: Option.Option }, ) => { const initial = normalizeStackFile(data ?? emptyStackFile); return Layer.effect( StackService, Effect.gen(function* () { const ref = yield* Ref.make(initial); return makeStackService({ loadData: () => Ref.get(ref), saveData: (next) => Ref.set(ref, normalizeStackFile(next)), currentBranch: () => Effect.succeed(options?.currentBranch ?? "test-branch"), detectTrunkCandidate: () => Effect.succeed( options?.detectTrunkCandidate !== undefined ? options.detectTrunkCandidate : Option.some(initial.trunk), ), }); }), ); }; }