diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9ba7813..8fdd785 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,5 +3,6 @@ export { pathExists, readJson, writeJson } from "./lib/fs.js"; export * from "./lib/error.js"; export * from "./lib/exec.js"; export { execute, resolvePlugins } from "./lib/planner.js"; +export { captureRollbackSnapshot, finalizeRollback } from "./lib/rollback.js"; export { DefaultStages } from "./lib/stage.js"; export type * from "./types.js"; diff --git a/packages/core/src/lib/context.ts b/packages/core/src/lib/context.ts index f1636ee..134b672 100644 --- a/packages/core/src/lib/context.ts +++ b/packages/core/src/lib/context.ts @@ -43,4 +43,33 @@ export interface Context { getData(key: string): T; hasData(key: string): boolean; setData(key: string, value: any): void; + + /** + * State used to roll back local changes if the release fails. + * Populated by `captureRollbackSnapshot` before the first non-`check` stage + * that may mutate the working tree. + */ + rollback?: RollbackState; +} + +export interface RollbackState { + /** SHA of HEAD before any release-related modifications were made */ + originalHead: string; + /** + * SHA of the stash commit that holds the user's pre-release uncommitted + * changes (only set when the working tree was dirty, i.e. with --all). + */ + stashSha?: string; + /** + * Unique message used to identify the snapshot stash in `git stash list`, + * since `git stash drop` requires a `stash@{N}` ref rather than a SHA. + */ + stashMessage?: string; + /** Set to true once the push stage starts. Disables rollback afterwards. */ + pushAttempted: boolean; + /** + * Name of the release tag created during this run (e.g. "v1.2.3"). Only set + * after `git tag` succeeds, so rollback never deletes a pre-existing tag. + */ + createdTag?: string; } diff --git a/packages/core/src/lib/planner.ts b/packages/core/src/lib/planner.ts index 12e54c0..53fd525 100644 --- a/packages/core/src/lib/planner.ts +++ b/packages/core/src/lib/planner.ts @@ -2,6 +2,7 @@ import { ReleaseError } from "../index.js"; import type { Context } from "./context.js"; import { GraphNode, topologicalSort } from "./graph.js"; import type { Plugin } from "./plugin.js"; +import { captureRollbackSnapshot } from "./rollback.js"; import { DefaultStages, type Stage } from "./stage.js"; /** Resolve all plugins that are required by the chosen plugins */ @@ -165,8 +166,17 @@ export async function execute(context: Context): Promise { ), ); } + let snapshotTaken = false; for (const stage of stages) { context.cli.prefix = `${stage.id}`; + // Snapshot the pre-release state before the first stage that may mutate + // the working tree. The `check` stage is read-only by convention; any + // later stage (including exec hooks like `after_check` / `before_edit`) + // might write files, so we capture before any of those run. + if (!snapshotTaken && stage.id !== DefaultStages.check.id) { + await captureRollbackSnapshot(context); + snapshotTaken = true; + } const plugins = await planStage(context, stage); if (context.argv.verbose) { context.cli.log( diff --git a/packages/core/src/lib/rollback.test.ts b/packages/core/src/lib/rollback.test.ts new file mode 100644 index 0000000..b565f0b --- /dev/null +++ b/packages/core/src/lib/rollback.test.ts @@ -0,0 +1,457 @@ +import { createMockContext } from "@alcalzone/release-script-testing"; +import { describe, expect, it } from "vitest"; +import { captureRollbackSnapshot, finalizeRollback } from "./rollback.js"; + +const HEAD_SHA = "1111111111111111111111111111111111111111"; +const STASH_SHA = "2222222222222222222222222222222222222222"; +const STASH_MSG = "release-script-rollback-12345"; + +// MockSystem is shared across tests via the default context. Reset call +// history (and any previous mockExec implementation) before each test so +// "not called" assertions are accurate. +function freshContext(...args: Parameters) { + const context = createMockContext(...args); + context.sys.exec.mockReset(); + context.sys.execRaw.mockReset(); + return context; +} + +describe("captureRollbackSnapshot", () => { + it("records HEAD when the working tree is clean", async () => { + const context = freshContext({}); + context.sys.mockExec({ + "git rev-parse HEAD": HEAD_SHA, + "git status --porcelain": "", + }); + + await captureRollbackSnapshot(context); + + expect(context.rollback).toBeDefined(); + expect(context.rollback?.originalHead).toBe(HEAD_SHA); + expect(context.rollback?.stashSha).toBeUndefined(); + expect(context.rollback?.stashMessage).toBeUndefined(); + expect(context.rollback?.pushAttempted).toBe(false); + }); + + it("creates and re-applies a stash when the working tree is dirty", async () => { + const context = freshContext({}); + context.sys.mockExec((cmd) => { + if (cmd === "git rev-parse HEAD") return HEAD_SHA; + if (cmd === "git status --porcelain") return " M package.json\n"; + if (cmd.startsWith("git stash push")) return ""; + if (cmd === "git stash apply --index stash@{0}") return ""; + if (cmd === "git rev-parse stash@{0}") return STASH_SHA; + throw new Error(`unexpected command: ${cmd}`); + }); + + await captureRollbackSnapshot(context); + + expect(context.rollback?.stashSha).toBe(STASH_SHA); + expect(context.rollback?.stashMessage).toMatch(/^release-script-rollback-/); + // Stash apply must be called so plugin edits + user edits coexist before commit + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["stash", "apply", "--index", "stash@{0}"], + expect.anything(), + ); + }); + + it("does not capture rollback state when git status cannot be determined", async () => { + const context = freshContext({}); + context.sys.mockExec((cmd) => { + if (cmd === "git rev-parse HEAD") return HEAD_SHA; + if (cmd === "git status --porcelain") throw new Error("status failed"); + throw new Error(`unexpected command: ${cmd}`); + }); + + await captureRollbackSnapshot(context); + + expect(context.rollback).toBeUndefined(); + expect( + context.warnings.some((w) => /rollback has been disabled for this run/i.test(w)), + ).toBe(true); + }); + + it("does not capture rollback state when stash creation fails on a dirty tree", async () => { + const context = freshContext({}); + context.sys.mockExec((cmd) => { + if (cmd === "git rev-parse HEAD") return HEAD_SHA; + if (cmd === "git status --porcelain") return " M package.json\n"; + if (cmd.startsWith("git stash push")) throw new Error("stash push failed"); + throw new Error(`unexpected command: ${cmd}`); + }); + + await captureRollbackSnapshot(context); + + expect(context.rollback).toBeUndefined(); + expect( + context.warnings.some((w) => /rollback has been disabled for this run/i.test(w)), + ).toBe(true); + }); + + it("preserves stashMessage when SHA capture fails after a successful apply", async () => { + const context = freshContext({}); + context.sys.mockExec((cmd) => { + if (cmd === "git rev-parse HEAD") return HEAD_SHA; + if (cmd === "git status --porcelain") return " M package.json\n"; + if (cmd.startsWith("git stash push")) return ""; + if (cmd === "git stash apply --index stash@{0}") return ""; + if (cmd === "git rev-parse stash@{0}") throw new Error("rev-parse failed"); + throw new Error(`unexpected command: ${cmd}`); + }); + + await captureRollbackSnapshot(context); + + // Apply succeeded, so the working tree matches the user's pre-release state. + // The stash entry remains as a recovery anchor identified by message. + expect(context.rollback?.stashMessage).toMatch(/^release-script-rollback-/); + expect(context.rollback?.stashSha).toBeUndefined(); + }); + + it("throws if apply fails after the snapshot stash was created", async () => { + // A failed apply leaves the working tree clean while the user's changes + // only live in the stash — proceeding would silently change the release + // contents, so capture must abort and let the caller surface the error. + const context = freshContext({}); + context.sys.mockExec((cmd) => { + if (cmd === "git rev-parse HEAD") return HEAD_SHA; + if (cmd === "git status --porcelain") return " M package.json\n"; + if (cmd.startsWith("git stash push")) return ""; + if (cmd === "git stash apply --index stash@{0}") throw new Error("conflict"); + throw new Error(`unexpected command: ${cmd}`); + }); + + await expect(captureRollbackSnapshot(context)).rejects.toThrow( + /Could not re-apply your uncommitted changes/, + ); + }); + + it("does nothing when --dryRun is set", async () => { + const context = freshContext({ argv: { dryRun: true } }); + await captureRollbackSnapshot(context); + expect(context.rollback).toBeUndefined(); + expect(context.sys.exec).not.toHaveBeenCalled(); + }); + + it("does nothing when --noRollback is set", async () => { + const context = freshContext({ argv: { noRollback: true } }); + await captureRollbackSnapshot(context); + expect(context.rollback).toBeUndefined(); + expect(context.sys.exec).not.toHaveBeenCalled(); + }); + + it("silently skips when not in a git repo", async () => { + const context = freshContext({}); + context.sys.mockExec(() => { + throw new Error("fatal: not a git repository"); + }); + await captureRollbackSnapshot(context); + expect(context.rollback).toBeUndefined(); + }); +}); + +describe("finalizeRollback (failure path)", () => { + it("is a no-op when no snapshot was captured", async () => { + const context = freshContext({}); + await finalizeRollback(context, { failed: true }); + expect(context.sys.exec).not.toHaveBeenCalled(); + }); + + it("resets HEAD and cleans untracked files when no tag and no stash", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + pushAttempted: false, + }; + context.sys.mockExec(() => ""); + + await finalizeRollback(context, { failed: true }); + + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["reset", "--hard", HEAD_SHA], + expect.anything(), + ); + expect(context.sys.exec).toHaveBeenCalledWith("git", ["clean", "-fd"], expect.anything()); + }); + + it("deletes the release tag only if this run created it", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + pushAttempted: false, + createdTag: "v1.2.3", + }; + context.sys.mockExec(() => ""); + + await finalizeRollback(context, { failed: true }); + + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["tag", "-d", "v1.2.3"], + expect.anything(), + ); + }); + + it("does NOT delete a pre-existing tag when this run did not create one", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + pushAttempted: false, + }; + // version_new is set but createdTag is not — tag pre-existed. + context.setData("version_new", "1.2.3"); + context.sys.mockExec(() => ""); + + await finalizeRollback(context, { failed: true }); + + expect(context.sys.exec).not.toHaveBeenCalledWith( + "git", + ["tag", "-d", "v1.2.3"], + expect.anything(), + ); + }); + + it("restores the user's stash and drops it by index after a clean rollback", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + stashSha: STASH_SHA, + stashMessage: STASH_MSG, + pushAttempted: false, + }; + context.sys.mockExec((cmd) => { + if (cmd === "git stash list --pretty=format:%gd %gs") { + return `stash@{0} On main: ${STASH_MSG}`; + } + return ""; + }); + + await finalizeRollback(context, { failed: true }); + + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["stash", "apply", "--index", STASH_SHA], + expect.anything(), + ); + // Drop must use the stash@{N} index, not the SHA + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["stash", "drop", "stash@{0}"], + expect.anything(), + ); + expect(context.sys.exec).not.toHaveBeenCalledWith( + "git", + ["stash", "drop", STASH_SHA], + expect.anything(), + ); + }); + + it("does NOT drop the stash if applying it failed", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + stashSha: STASH_SHA, + stashMessage: STASH_MSG, + pushAttempted: false, + }; + context.sys.mockExec((cmd) => { + if (cmd === `git stash apply --index ${STASH_SHA}`) { + throw new Error("conflict"); + } + if (cmd === "git stash list --pretty=format:%gd %gs") { + return `stash@{0} On main: ${STASH_MSG}`; + } + return ""; + }); + + await finalizeRollback(context, { failed: true }); + + expect(context.sys.exec).not.toHaveBeenCalledWith( + "git", + ["stash", "drop", "stash@{0}"], + expect.anything(), + ); + expect(context.warnings.some((w) => /uncommitted changes/i.test(w))).toBe(true); + }); + + it("restores the stash via message-based lookup when stashSha is missing", async () => { + // Captures the case where the SHA couldn't be resolved at snapshot time + // (e.g. transient `git rev-parse` failure) but the stash entry exists + // and is identifiable by its message. + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + stashMessage: STASH_MSG, + pushAttempted: false, + }; + context.sys.mockExec((cmd) => { + if (cmd === "git stash list --pretty=format:%gd %gs") { + return `stash@{0} On main: ${STASH_MSG}`; + } + return ""; + }); + + await finalizeRollback(context, { failed: true }); + + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["stash", "apply", "--index", "stash@{0}"], + expect.anything(), + ); + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["stash", "drop", "stash@{0}"], + expect.anything(), + ); + }); + + it("does NOT drop the stash when message-based restore fails", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + stashMessage: STASH_MSG, + pushAttempted: false, + }; + context.sys.mockExec((cmd) => { + if (cmd === "git stash list --pretty=format:%gd %gs") { + return `stash@{0} On main: ${STASH_MSG}`; + } + if (cmd === "git stash apply --index stash@{0}") { + throw new Error("conflict"); + } + return ""; + }); + + await finalizeRollback(context, { failed: true }); + + expect(context.sys.exec).not.toHaveBeenCalledWith( + "git", + ["stash", "drop", "stash@{0}"], + expect.anything(), + ); + expect(context.warnings.some((w) => /uncommitted changes/i.test(w))).toBe(true); + }); + + it("drops the snapshot stash even when push was already attempted", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + stashSha: STASH_SHA, + stashMessage: STASH_MSG, + pushAttempted: true, + }; + context.sys.mockExec((cmd) => { + if (cmd === "git stash list --pretty=format:%gd %gs") { + return `stash@{0} On main: ${STASH_MSG}`; + } + return ""; + }); + + await finalizeRollback(context, { failed: true }); + + // Did not roll back HEAD + expect(context.sys.exec).not.toHaveBeenCalledWith( + "git", + ["reset", "--hard", HEAD_SHA], + expect.anything(), + ); + // But did clean up the stash (its contents are in the local commit) + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["stash", "drop", "stash@{0}"], + expect.anything(), + ); + expect(context.warnings.some((w) => /push.*already.*attempted/i.test(w))).toBe(true); + }); + + it("does nothing when --dryRun is set", async () => { + const context = freshContext({ argv: { dryRun: true } }); + context.rollback = { + originalHead: HEAD_SHA, + pushAttempted: false, + }; + await finalizeRollback(context, { failed: true }); + expect(context.sys.exec).not.toHaveBeenCalled(); + }); + + it("does nothing when --noRollback is set", async () => { + const context = freshContext({ argv: { noRollback: true } }); + context.rollback = { + originalHead: HEAD_SHA, + pushAttempted: false, + }; + await finalizeRollback(context, { failed: true }); + expect(context.sys.exec).not.toHaveBeenCalled(); + }); +}); + +describe("finalizeRollback (success path)", () => { + it("drops the snapshot stash by index", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + stashSha: STASH_SHA, + stashMessage: STASH_MSG, + pushAttempted: false, + }; + context.sys.mockExec((cmd) => { + if (cmd === "git stash list --pretty=format:%gd %gs") { + return `stash@{2} On main: unrelated\nstash@{1} On main: ${STASH_MSG}\nstash@{0} On main: other`; + } + return ""; + }); + + await finalizeRollback(context, { failed: false }); + + expect(context.sys.exec).toHaveBeenCalledWith( + "git", + ["stash", "drop", "stash@{1}"], + expect.anything(), + ); + // Must not touch HEAD or the working tree on the success path + expect(context.sys.exec).not.toHaveBeenCalledWith( + "git", + ["reset", "--hard", HEAD_SHA], + expect.anything(), + ); + }); + + it("is a no-op when no snapshot was taken", async () => { + const context = freshContext({}); + await finalizeRollback(context, { failed: false }); + expect(context.sys.exec).not.toHaveBeenCalled(); + }); + + it("is a no-op when no stash was created (clean working tree)", async () => { + const context = freshContext({}); + context.rollback = { + originalHead: HEAD_SHA, + pushAttempted: false, + }; + await finalizeRollback(context, { failed: false }); + expect(context.sys.exec).not.toHaveBeenCalled(); + }); + + it("is a no-op when --dryRun or --noRollback is set", async () => { + const dry = freshContext({ argv: { dryRun: true } }); + dry.rollback = { + originalHead: HEAD_SHA, + stashSha: STASH_SHA, + stashMessage: STASH_MSG, + pushAttempted: false, + }; + await finalizeRollback(dry, { failed: false }); + expect(dry.sys.exec).not.toHaveBeenCalled(); + + const noRb = freshContext({ argv: { noRollback: true } }); + noRb.rollback = { + originalHead: HEAD_SHA, + stashSha: STASH_SHA, + stashMessage: STASH_MSG, + pushAttempted: false, + }; + await finalizeRollback(noRb, { failed: false }); + expect(noRb.sys.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/lib/rollback.ts b/packages/core/src/lib/rollback.ts new file mode 100644 index 0000000..b4da249 --- /dev/null +++ b/packages/core/src/lib/rollback.ts @@ -0,0 +1,238 @@ +import type { Context, RollbackState } from "./context.js"; + +const ROLLBACK_STASH_MESSAGE_PREFIX = "release-script-rollback-"; + +async function execGit( + context: Context, + args: string[], +): Promise<{ stdout: string; stderr: string }> { + const result = await context.sys.exec("git", args, { cwd: context.cwd }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +} + +/** Look up the `stash@{N}` ref of a stash entry by its message. */ +async function findStashIndexByMessage( + context: Context, + stashMessage: string, +): Promise { + try { + const { stdout } = await execGit(context, ["stash", "list", "--pretty=format:%gd %gs"]); + for (const line of stdout.split(/\r?\n/)) { + if (!line) continue; + const match = /^(stash@\{\d+\})\s/.exec(line); + if (match && line.includes(stashMessage)) { + return match[1]; + } + } + } catch { + /* ignore */ + } + return undefined; +} + +async function dropRollbackStash(context: Context, state: RollbackState): Promise { + if (!state.stashMessage) return; + const index = await findStashIndexByMessage(context, state.stashMessage); + if (!index) { + // Already gone or never created — nothing to do. + return; + } + try { + await execGit(context, ["stash", "drop", index]); + } catch (e: any) { + context.cli.warn(`Failed to drop rollback stash ${index}: ${e?.message ?? e}`); + } +} + +/** + * Captures the state required to roll back any release-induced changes. + * Should be called once, just before the first stage that mutates the working tree. + */ +export async function captureRollbackSnapshot(context: Context): Promise { + if (context.argv.dryRun) return; + if (context.argv.noRollback) return; + if (context.rollback) return; // already captured + + let originalHead: string; + try { + const { stdout } = await execGit(context, ["rev-parse", "HEAD"]); + originalHead = stdout.trim(); + } catch { + // Not a git repo (or git unavailable) — skip rollback support entirely. + return; + } + + let isDirty: boolean; + try { + const { stdout } = await execGit(context, ["status", "--porcelain"]); + isDirty = stdout.trim() !== ""; + } catch (e: any) { + context.cli.warn( + `Could not determine working tree status for rollback: ${e?.message ?? e}. ` + + `Local rollback has been disabled for this run to avoid data loss if the release fails.`, + ); + return; + } + + let stashSha: string | undefined; + let stashMessage: string | undefined; + if (isDirty) { + const message = `${ROLLBACK_STASH_MESSAGE_PREFIX}${Date.now()}`; + try { + await execGit(context, ["stash", "push", "--include-untracked", "-m", message]); + // From this line on, the stash entry exists in `git stash list` and + // is identifiable by its message. + stashMessage = message; + } catch (e: any) { + context.cli.warn( + `Failed to snapshot uncommitted changes for rollback: ${e?.message ?? e}. ` + + `Local rollback has been disabled for this run to avoid data loss if the release fails.`, + ); + return; + } + + if (stashMessage) { + // Re-apply immediately so the release operates on the same working + // tree the user had. `--index` preserves the staged/unstaged split. + // If apply fails the working tree is now clean while the user's + // changes only live in the stash — proceeding would silently change + // what the release contains, so abort instead. + try { + await execGit(context, ["stash", "apply", "--index", "stash@{0}"]); + } catch (e: any) { + throw new Error( + `Could not re-apply your uncommitted changes after snapshotting them ` + + `for rollback: ${e?.message ?? e}. ` + + `Your changes are preserved in the stash list (look for "${stashMessage}"); ` + + `recover them with: git stash apply stash^{/${stashMessage}}`, + ); + } + + // Capture the SHA as a stable handle for the later restore in + // finalizeRollback. Best-effort: if this fails we can fall back to + // looking the entry up by its unique message. + try { + const { stdout } = await execGit(context, ["rev-parse", "stash@{0}"]); + stashSha = stdout.trim(); + } catch { + /* fall back to message-based lookup */ + } + } + } + + context.rollback = { + originalHead, + stashSha, + stashMessage, + pushAttempted: false, + }; +} + +export interface FinalizeRollbackOptions { + /** + * Whether the release failed. If true, the working tree, HEAD, and any + * release tag are reverted. If false, only the snapshot stash is dropped. + */ + failed: boolean; +} + +/** + * Single entry point for the rollback lifecycle, called once after the release + * finishes (success or failure). Acts as the inverse of `captureRollbackSnapshot`. + */ +export async function finalizeRollback( + context: Context, + options: FinalizeRollbackOptions, +): Promise { + if (context.argv.dryRun) return; + if (context.argv.noRollback) return; + + const state = context.rollback; + if (!state) return; + + if (!options.failed) { + // Success path: any snapshot stash was re-applied to the working tree + // during capture (captureRollbackSnapshot aborts otherwise), so its + // contents are part of the release commit and the entry can be dropped. + await dropRollbackStash(context, state); + return; + } + + if (state.pushAttempted) { + context.cli.warn( + `Skipping rollback because a push to the remote has already been attempted. ` + + `Local commit and tag have been kept so you can decide how to proceed.`, + ); + // The user's pre-release changes (if any) are already in the local + // release commit, so the snapshot stash is no longer useful. + await dropRollbackStash(context, state); + return; + } + + context.cli.log("Rolling back local changes..."); + + // Only delete the tag if this run actually created it. Tags that pre-date + // this attempt (e.g. left over from a previous failed run) must be left + // alone for the user to inspect. + if (state.createdTag) { + try { + await execGit(context, ["tag", "-d", state.createdTag]); + context.cli.log(`Deleted release tag ${state.createdTag}`); + } catch (e: any) { + context.cli.warn( + `Failed to delete release tag ${state.createdTag}: ${e?.message ?? e}`, + ); + } + } + + // Reset HEAD and working tree to the snapshot commit + try { + await execGit(context, ["reset", "--hard", state.originalHead]); + context.cli.log(`Reset HEAD to ${state.originalHead.slice(0, 8)}`); + } catch (e: any) { + context.cli.warn(`Failed to reset to original HEAD: ${e?.message ?? e}`); + return; + } + + // Drop untracked files left behind by plugins (e.g. .commitmessage). Do not + // pass -x — that would also remove .gitignore'd files like node_modules. + try { + await execGit(context, ["clean", "-fd"]); + } catch (e: any) { + context.cli.warn(`Failed to clean untracked files: ${e?.message ?? e}`); + } + + // Restore the user's pre-release uncommitted changes from the stash + // snapshot. Prefer the SHA (stable across other stash operations); if it + // wasn't captured, look the entry up by its unique message. + let stashApplied = false; + if (state.stashMessage || state.stashSha) { + const ref = + state.stashSha ?? + (state.stashMessage + ? await findStashIndexByMessage(context, state.stashMessage) + : undefined); + if (ref) { + try { + // `--index` preserves the staged/unstaged split the user had. + await execGit(context, ["stash", "apply", "--index", ref]); + stashApplied = true; + context.cli.log("Restored your pre-release uncommitted changes"); + } catch (e: any) { + const recoveryHint = state.stashMessage + ? `Recover them manually with: git stash list (look for "${state.stashMessage}")` + : `Recover them manually via the stash list.`; + context.cli.warn( + `Failed to restore pre-release uncommitted changes: ${e?.message ?? e}. ` + + recoveryHint, + ); + } + } + } + + // Drop the snapshot stash only when there was nothing to restore, or the + // restore succeeded. Otherwise keep it as the user's recovery anchor. + if (!state.stashMessage || stashApplied) { + await dropRollbackStash(context, state); + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d55eba8..5bf3c2e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,5 @@ export type { CLI, SelectOption } from "./lib/cli.js"; -export type { Context } from "./lib/context.js"; +export type { Context, RollbackState } from "./lib/context.js"; export type { Plugin } from "./lib/plugin.js"; export type { ConstOrDynamic } from "./lib/shared.js"; export type { Stage } from "./lib/stage.js"; diff --git a/packages/plugin-git/src/index.test.ts b/packages/plugin-git/src/index.test.ts index b5a3bba..523960c 100644 --- a/packages/plugin-git/src/index.test.ts +++ b/packages/plugin-git/src/index.test.ts @@ -435,6 +435,83 @@ This is the changelog.`); expect(context.sys.execRaw).toHaveBeenCalledWith(cmd, expect.anything()); } }); + + it("marks rollback.pushAttempted=true once a real push is dispatched", async () => { + const gitPlugin = new GitPlugin(); + const context = createMockContext({ plugins: [gitPlugin] }); + context.setData("version_new", "1.2.9"); + context.rollback = { + originalHead: "deadbeef", + pushAttempted: false, + }; + context.sys.mockExec(() => ""); + + await gitPlugin.executeStage(context, DefaultStages.push); + + expect(context.rollback.pushAttempted).toBe(true); + }); + + it("leaves rollback.pushAttempted=false when pre-push setup fails", async () => { + // If a pre-push step (e.g. `getUpstream()`) throws before any push is + // dispatched, the release never contacted the remote — rollback must + // still be allowed. + const gitPlugin = new GitPlugin(); + const context = createMockContext({ + plugins: [gitPlugin], + // Force getUpstream() to be called by clearing the configured remote. + argv: { remote: "" }, + }); + context.setData("version_new", "1.2.9b"); + context.rollback = { + originalHead: "deadbeef", + pushAttempted: false, + }; + context.sys.mockExec((cmd) => { + if (cmd.includes("rev-parse --abbrev-ref --symbolic-full-name")) { + throw new Error("no upstream configured"); + } + return ""; + }); + + await expect(gitPlugin.executeStage(context, DefaultStages.push)).rejects.toThrow(); + + expect(context.rollback.pushAttempted).toBe(false); + }); + + it("does not mark rollback.pushAttempted in dry run", async () => { + const gitPlugin = new GitPlugin(); + const context = createMockContext({ + plugins: [gitPlugin], + argv: { dryRun: true }, + }); + context.setData("version_new", "1.3.0"); + context.rollback = { + originalHead: "deadbeef", + pushAttempted: false, + }; + context.sys.mockExec(() => ""); + + await gitPlugin.executeStage(context, DefaultStages.push); + + expect(context.rollback.pushAttempted).toBe(false); + }); + + it("does not mark rollback.pushAttempted when --noPush is set", async () => { + const gitPlugin = new GitPlugin(); + const context = createMockContext({ + plugins: [gitPlugin], + argv: { noPush: true }, + }); + context.setData("version_new", "1.3.1"); + context.rollback = { + originalHead: "deadbeef", + pushAttempted: false, + }; + + await gitPlugin.executeStage(context, DefaultStages.push); + + expect(context.rollback.pushAttempted).toBe(false); + }); }); describe("cleanup stage", () => { diff --git a/packages/plugin-git/src/index.ts b/packages/plugin-git/src/index.ts index 038b67f..e9ce3b3 100644 --- a/packages/plugin-git/src/index.ts +++ b/packages/plugin-git/src/index.ts @@ -249,16 +249,24 @@ ${context.getData("changelog_new")}`; } // And commit stuff + const tagName = `v${newVersion}`; + const tagCommand = ["git", "tag", "-a", tagName, "-m", tagName]; const commands = [ ["git", "add", "-A", "--", ":(exclude).commitmessage"], ["git", "commit", "-F", ".commitmessage"], - ["git", "tag", "-a", `v${newVersion}`, "-m", `v${newVersion}`], + tagCommand, ]; - for (const [cmd, ...args] of commands) { + for (const command of commands) { + const [cmd, ...args] = command; context.cli.logCommand(cmd, args); if (!context.argv.dryRun) { await context.sys.exec(cmd, args, { cwd: context.cwd }); + // Record the tag name only after `git tag` succeeds, so rollback + // never deletes a pre-existing tag of the same name. + if (command === tagCommand && context.rollback) { + context.rollback.createdTag = tagName; + } } } } @@ -289,6 +297,11 @@ ${context.getData("changelog_new")}`; for (const command of commands) { context.cli.logCommand(command); if (!context.argv.dryRun) { + // Remember that we attempted to push immediately before the first + // actual push command, not during information gathering. + if (context.rollback) { + context.rollback.pushAttempted = true; + } await context.sys.execRaw(command, { cwd: context.cwd }); } } diff --git a/packages/release-script/src/index.ts b/packages/release-script/src/index.ts index 36ee629..6236fea 100644 --- a/packages/release-script/src/index.ts +++ b/packages/release-script/src/index.ts @@ -1,9 +1,10 @@ import { - type CLI as ICLI, type Context, exec, execRaw, execute, + finalizeRollback, + type CLI as ICLI, isReleaseError, type Plugin, ReleaseError, @@ -199,6 +200,12 @@ export async function main(): Promise { description: `Bump and publish all non-private packages in monorepos, even if they didn't change`, default: false, }, + noRollback: { + type: "boolean", + description: + "Do not roll back local changes (file edits, commits, tags) if the release fails before pushing", + default: false, + }, }); // We do two-pass parsing: @@ -276,6 +283,9 @@ export async function main(): Promise { }; context.cli = new CLI(context); + let failed = false; + let exitCode: number | undefined; + try { // Initialize plugins for (const plugin of plugins) { @@ -300,7 +310,8 @@ export async function main(): Promise { message += "!"; console.error(); console.error(message); - process.exit(1); + failed = true; + exitCode = 1; } } catch (e: any) { if (isReleaseError(e)) { @@ -323,7 +334,32 @@ export async function main(): Promise { ), ); } - process.exit((e as any).code ?? 1); + failed = true; + exitCode = (e as any).code ?? 1; + } + + try { + await finalizeRollback(context, { failed }); + } catch (rollbackError: any) { + const msg = rollbackError?.stack ?? rollbackError?.message ?? String(rollbackError); + console.error( + prependPrefix( + context.cli.prefix, + colorizeTextAndTags( + `[FATAL] Rollback itself failed: ${msg}`, + colors.red, + colors.bgRed, + ), + ), + ); + // A rollback that throws leaves the working tree in an unknown state — + // always surface a non-zero exit so automation can detect it, even if + // the release itself was successful up to that point. + exitCode ??= 1; + } + + if (exitCode !== undefined) { + process.exit(exitCode); } }