import { Command, Flag } from "effect/unstable/cli"; import { Console, Effect, FileSystem, Option } from "effect"; import { GitService } from "../services/Git.js"; import { StackService } from "../services/Stack.js"; import { ErrorCode, StackError } from "../errors/index.js"; import { success, withSpinner } from "../ui.js"; const editFlag = Flag.boolean("edit").pipe(Flag.withDescription("Open editor for commit message")); const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON")); const fromFlag = Flag.string("from").pipe( Flag.optional, Flag.withDescription("Start syncing from this branch (defaults to current)"), ); interface SyncState { version: 1; conflictedBranch: string; newBaseTip: string; stackName: string; originalBranch: string; remainingBranches: string[]; } const syncStatePath = (gitDir: string) => `${gitDir}/stacked-sync-state.json`; const writeSyncState = (gitDir: string, state: SyncState) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = syncStatePath(gitDir); const tmpPath = `${path}.tmp`; yield* fs.writeFileString(tmpPath, JSON.stringify(state, null, 2)); yield* fs.rename(tmpPath, path); }).pipe( Effect.mapError(() => new StackError({ message: "Failed to write sync state" })), Effect.asVoid, ); export const amend = Command.make("amend", { edit: editFlag, json: jsonFlag, from: fromFlag, }).pipe( Command.withDescription("Amend current commit and merge into children"), Command.withExamples([ { command: "stacked amend", description: "Amend and auto-merge into children" }, { command: "stacked amend --edit", description: "Amend with editor" }, ]), Command.withHandler(({ edit, json, from }) => Effect.gen(function* () { const git = yield* GitService; const stacks = yield* StackService; const currentBranch = yield* git.currentBranch(); const lineage = yield* stacks.currentLineage(); if (lineage === null) { return yield* new StackError({ code: ErrorCode.NOT_IN_STACK, message: "Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create ' to start one.", }); } const fromBranch = Option.isSome(from) ? from.value : currentBranch; const idx = lineage.branches.findIndex((entry) => entry.name === fromBranch); if (idx === -1) { return yield* new StackError({ code: ErrorCode.BRANCH_NOT_FOUND, message: `Branch "${fromBranch}" not found in stack "${lineage.name}"`, }); } yield* git.commitAmend({ edit }); if (idx >= lineage.branches.length - 1) { if (json) { // @effect-diagnostics-next-line effect/preferSchemaOverJson:off yield* Console.log(JSON.stringify({ amended: currentBranch, synced: [] }, null, 2)); } else { yield* success(`Amended ${currentBranch} (no children to sync)`); } return; } const children = lineage.branches.slice(idx + 1); const synced: string[] = []; const gitDir = yield* git.revParse("--absolute-git-dir"); yield* Effect.gen(function* () { for (let i = 0; i < children.length; i++) { const entry = children[i]; if (entry === undefined) continue; const branch = entry.name; const newBase = entry.activeParent; const newBaseTip = yield* git.revParse(newBase); const branchHead = yield* git.revParse(branch); const syncedOnto = yield* stacks.getSyncedOnto(branch); if (syncedOnto !== null && syncedOnto === newBaseTip) { synced.push(branch); continue; } const alreadyIncorporated = yield* git .isAncestor(newBaseTip, branchHead) .pipe(Effect.catchTag("GitError", () => Effect.succeed(false))); if (alreadyIncorporated) { yield* stacks.updateSyncedOnto(branch, newBaseTip); synced.push(branch); continue; } yield* git.checkout(branch); const mergeResult = yield* git .mergeBranch({ base: newBase, message: `sync: merge ${newBase} into ${branch}` }) .pipe( Effect.catchTag("GitError", () => Effect.succeed({ action: "conflict" as const })), ); if (mergeResult.action === "conflict") { yield* writeSyncState(gitDir, { version: 1, conflictedBranch: branch, newBaseTip, stackName: lineage.name, originalBranch: currentBranch, remainingBranches: children.slice(i + 1).map((child) => child.name), }); const files = yield* git .conflictedFiles() .pipe(Effect.orElseSucceed(() => [] as string[])); const fileList = files.length > 0 ? `\n${files.map((f) => ` ${f}`).join("\n")}` : ""; return yield* new StackError({ code: ErrorCode.SYNC_CONFLICT, message: `Conflict on ${branch} (${files.length} file${files.length === 1 ? "" : "s"}):${fileList}\n\nResolve conflicts, then:\n git add && stacked sync --continue\n\nOr abort:\n stacked sync --abort`, }); } yield* stacks.updateSyncedOnto(branch, newBaseTip); if (mergeResult.action === "merged") { yield* withSpinner(`Pushing ${branch}`, git.push(branch)); } synced.push(branch); } }).pipe( Effect.ensuring( git .isMergeInProgress() .pipe( Effect.andThen((inProgress) => inProgress ? Effect.void : git.checkout(currentBranch).pipe(Effect.ignore), ), ), ), ); if (json) { // @effect-diagnostics-next-line effect/preferSchemaOverJson:off yield* Console.log(JSON.stringify({ amended: currentBranch, synced }, null, 2)); } else { yield* success( `Amended ${currentBranch} and merged into ${synced.length} child branch(es)`, ); } }), ), );