/**
* Workspace change manifest — `/goal-clear` rollback.
*
* When the user clears a goal, they may also roll the workspace back to the
* goal's baseline. The window delta (see `goal-change-delta.ts`) is the single
* source of truth for what changed, so the audit and the rollback can never
* disagree about the change set.
*
* Safety model (see design D11–D13):
* 1. plan — derive restore/delete/unrecoverable actions from the delta;
* 2. backup — write the discarded changes into the goal's archive directory
* (per-repo `git diff --binary` patch plus copies of every file
* the rollback will delete). The worktree is not touched until
* the whole backup is on disk — a partially written backup
* aborts the rollback (fail-closed);
* 3. execute — `git restore --source= --worktree` (never the index),
* delete window-created files, prune emptied directories, and
* never reset any repository HEAD;
* 4. report — per-repo counts, every failure with its path, every item that
* could not be rolled back, and the backup location.
*
* Nothing here writes to the user's stash list or runs a git pruning command.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { runGit } from "./goal-change-manifest.ts";
import type { RepoChangeEntry, RepoDelta, ChangeDelta } from "./goal-change-delta.ts";
import { ARCHIVED_GOALS_DIR, ensureDirectory, isSafeRelativeUnder, type GoalFileContext } from "./storage/goal-files.ts";
/** Total backup size ceiling; exceeding it aborts the rollback (never a partial backup). */
export const MAX_ROLLBACK_BACKUP_BYTES = 32 * 1024 * 1024;
export type RollbackActionKind = "restore" | "delete" | "unrecoverable" | "head-moved";
export interface RollbackAction {
kind: RollbackActionKind;
/** Path relative to the repository root (forward slashes). */
path: string;
/** Original path for a rename. */
origPath?: string;
/** Human-readable reason, required for `unrecoverable`/`head-moved`. */
reason?: string;
}
export interface RepoRollbackPlan {
root: string;
kind: RepoDelta["kind"];
relativePath?: string;
/** Baseline reference to restore from (null = status-only baseline). */
base: string | null;
baseKind: RepoDelta["baseKind"];
actions: RollbackAction[];
}
export interface RollbackPlan {
goalId: string;
repos: RepoRollbackPlan[];
restoreCount: number;
deleteCount: number;
unrecoverableCount: number;
}
export interface RollbackRepoResult {
root: string;
restored: number;
deleted: number;
failures: Array<{ path: string; reason: string }>;
unrecoverable: RollbackAction[];
headMoved: RollbackAction[];
}
export interface RollbackBackupResult {
ok: boolean;
/** Absolute backup directory, when a backup was written. */
dir: string | null;
/** Why the backup failed (fail-closed: no rollback is attempted). */
reason?: string;
bytes: number;
}
const ROLLBACK_DIR_PREFIX = "rollback_";
/** `rollback__/`, mirroring `makeArchivedGoalPath`'s naming. */
export function rollbackBackupDirName(goalId: string, at: Date = new Date()): string {
const pad = (value: number, width = 2): string => String(value).padStart(width, "0");
const stamp = [
at.getFullYear(),
pad(at.getMonth() + 1),
pad(at.getDate()),
pad(at.getHours()),
pad(at.getMinutes()),
pad(at.getSeconds()),
pad(Math.floor(at.getMilliseconds() / 10)),
].join("");
const safeId = goalId.replace(/[^A-Za-z0-9._-]/g, "_");
return `${ROLLBACK_DIR_PREFIX}${stamp}_${safeId}`;
}
/** Filename-safe repository slug (path-derived, never contains separators). */
function repoSlug(root: string, index: number): string {
const name = path.basename(root).replace(/[^A-Za-z0-9._-]/g, "_") || "repo";
return `${index + 1}-${name}`;
}
/** Guard: a planned path must stay inside its repository root. */
function resolveInsideRepo(root: string, relativePath: string): string | undefined {
const abs = path.resolve(root, relativePath);
if (abs !== root && !abs.startsWith(root + path.sep)) return undefined;
return abs;
}
/**
* Turn the window delta into concrete rollback actions.
*
* - modified / renamed / type-changed tracked files → restore from the baseline
* - deleted tracked files → restore from the baseline
* - files created inside the window (added / untracked, and the new side of a
* rename) → delete
* - baseline-untracked files whose content changed, and baseline-untracked files
* deleted in the window → unrecoverable: the stash baseline stores tracked
* content only, so the pre-window bytes no longer exist anywhere. The file is
* left alone and reported instead of being guessed at.
* - moved HEADs → never reset
*/
export function planRollback(delta: ChangeDelta): RollbackPlan {
const repos: RepoRollbackPlan[] = delta.repos.map((repo) => {
const actions: RollbackAction[] = [];
for (const entry of repo.entries) {
actions.push(...planEntry(entry));
}
if (repo.headMoved) {
actions.push({
kind: "head-moved",
path: repo.relativePath ?? ".",
reason: `HEAD moved ${short(repo.headMoved.from)} -> ${short(repo.headMoved.to)}; commit history is never rewritten`,
});
}
return {
root: repo.root,
kind: repo.kind,
...(repo.relativePath ? { relativePath: repo.relativePath } : {}),
base: repo.base,
baseKind: repo.baseKind,
actions,
};
});
const all = repos.flatMap((repo) => repo.actions);
return {
goalId: delta.goalId,
repos,
restoreCount: all.filter((action) => action.kind === "restore").length,
deleteCount: all.filter((action) => action.kind === "delete").length,
unrecoverableCount: all.filter((action) => action.kind === "unrecoverable").length,
};
}
function planEntry(entry: RepoChangeEntry): RollbackAction[] {
switch (entry.status) {
case "modified":
case "type-changed":
return [action("restore", entry.path)];
case "deleted":
// `??-`: the file was untracked at baseline, so its bytes are gone.
return entry.code === "??-"
? [action("unrecoverable", entry.path, "untracked at baseline and deleted in the window; no pre-window copy exists")]
: [action("restore", entry.path)];
case "renamed":
// Restore the original content and remove the new name; the index is left alone.
return [
...(entry.origPath ? [{ kind: "restore" as const, path: entry.origPath }] : []),
{ kind: "delete" as const, path: entry.path },
];
case "added":
case "untracked":
return [action("delete", entry.path)];
case "untracked-modified":
return [action("unrecoverable", entry.path, "untracked at baseline and modified in the window; the stash baseline stores tracked content only")];
default:
return [];
}
}
function action(kind: RollbackActionKind, filePath: string, reason?: string): RollbackAction {
return reason ? { kind, path: filePath, reason } : { kind, path: filePath };
}
function short(sha: string | null): string {
return sha ? sha.slice(0, 10) : "(none)";
}
/** Files the rollback will delete inside one repository. */
function deletablePaths(
repo: RepoRollbackPlan,
): Array<{ relativePath: string; abs: string }> {
const output: Array<{ relativePath: string; abs: string }> = [];
for (const entry of repo.actions) {
if (entry.kind !== "delete") continue;
const abs = resolveInsideRepo(repo.root, entry.path);
if (!abs) continue;
output.push({ relativePath: entry.path, abs });
}
return output;
}
/**
* Write the rollback backup: per-repo patch of the window delta plus a copy of
* every file the rollback would delete. The caller MUST treat `ok: false` as
* "do not touch the worktree".
*/
export async function writeRollbackBackup(
ctx: GoalFileContext,
plan: RollbackPlan,
options: { now?: Date; maxBytes?: number } = {},
): Promise {
const maxBytes = options.maxBytes ?? MAX_ROLLBACK_BACKUP_BYTES;
const dirName = rollbackBackupDirName(plan.goalId, options.now ?? new Date());
const relDir = `${ARCHIVED_GOALS_DIR}/${dirName}`;
if (!isSafeRelativeUnder(ctx, ARCHIVED_GOALS_DIR, relDir)) {
return { ok: false, dir: null, reason: `unsafe backup path: ${relDir}`, bytes: 0 };
}
let dir: string;
try {
ensureDirectory(ctx, relDir);
dir = path.resolve(ctx.cwd, relDir);
} catch (error) {
return { ok: false, dir: null, reason: `cannot create backup directory: ${messageOf(error)}`, bytes: 0 };
}
let bytes = 0;
const total = (): number => bytes;
try {
const manifest: Record = {
version: 1,
goalId: plan.goalId,
createdAt: (options.now ?? new Date()).toISOString(),
files: [] as string[],
repos: [] as unknown[],
unrecoverable: [] as unknown[],
};
const files: string[] = manifest.files as string[];
const repos: unknown[] = manifest.repos as unknown[];
for (let index = 0; index < plan.repos.length; index += 1) {
const repo = plan.repos[index];
if (!repo) continue;
const slug = repoSlug(repo.root, index);
let patchFile: string | null = null;
// A patch of the window delta: applying it restores the pre-rollback
// state of every tracked file the rollback touches.
if (repo.base) {
const diff = await runGit(["diff", "--binary", "--no-color", repo.base], repo.root);
if (!diff.ok) {
return { ok: false, dir, reason: `git diff failed for ${repo.root}: ${diff.stderr.trim() || "unknown error"}`, bytes: total() };
}
if (diff.stdout.length > 0) {
patchFile = `${slug}.patch`;
fs.writeFileSync(path.join(dir, patchFile), diff.stdout, "utf8");
bytes += Buffer.byteLength(diff.stdout);
// The cap covers patch bytes too: an oversized backup must refuse
// before any delete/restore is attempted.
if (bytes > maxBytes) {
return {
ok: false,
dir,
reason: `backup exceeds the ${Math.round(maxBytes / (1024 * 1024))} MiB cap; rollback refused so nothing is lost`,
bytes: total(),
};
}
}
}
const copied: string[] = [];
for (const target of deletablePaths(repo)) {
// lstat: a symlink must be preserved as a link, never followed out of the repo.
const stat = fs.existsSync(target.abs) ? fs.lstatSync(target.abs) : undefined;
if (!stat) continue; // already gone: nothing to preserve
const destination = path.join(dir, "files", slug, ...target.relativePath.split("/"));
fs.mkdirSync(path.dirname(destination), { recursive: true });
if (stat.isSymbolicLink()) {
fs.writeFileSync(destination, fs.readlinkSync(target.abs), "utf8");
} else if (stat.isFile()) {
fs.copyFileSync(target.abs, destination);
} else {
continue; // directories are not copied; their files are listed individually
}
bytes += stat.size;
if (bytes > maxBytes) {
return {
ok: false,
dir,
reason: `backup exceeds the ${Math.round(maxBytes / (1024 * 1024))} MiB cap; rollback refused so nothing is lost`,
bytes: total(),
};
}
copied.push(target.relativePath);
files.push(`${slug}/${target.relativePath}`);
}
repos.push({
root: repo.root,
kind: repo.kind,
base: repo.base,
baseKind: repo.baseKind,
patch: patchFile,
copiedFiles: copied,
unrecoverable: repo.actions.filter((entry) => entry.kind === "unrecoverable"),
headMoved: repo.actions.filter((entry) => entry.kind === "head-moved"),
});
}
fs.writeFileSync(path.join(dir, "manifest.json"), `${JSON.stringify(manifest, null, "\t")}\n`, "utf8");
return { ok: true, dir, bytes: total() };
} catch (error) {
return { ok: false, dir, reason: `backup write failed: ${messageOf(error)}`, bytes: total() };
}
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Apply the plan. Every failure is collected per path instead of aborting, so a
* partial rollback is reported precisely rather than silently half-done.
*/
export async function executeRollback(plan: RollbackPlan): Promise {
const results: RollbackRepoResult[] = [];
for (const repo of plan.repos) {
const result: RollbackRepoResult = {
root: repo.root,
restored: 0,
deleted: 0,
failures: [],
unrecoverable: repo.actions.filter((entry) => entry.kind === "unrecoverable"),
headMoved: repo.actions.filter((entry) => entry.kind === "head-moved"),
};
for (const entry of repo.actions) {
if (entry.kind === "restore") {
const abs = resolveInsideRepo(repo.root, entry.path);
if (!abs) {
result.failures.push({ path: entry.path, reason: "path escapes the repository root" });
continue;
}
if (!repo.base) {
result.failures.push({ path: entry.path, reason: "no baseline reference to restore from" });
continue;
}
// Worktree only: the index and the user's commit history stay untouched.
const restored = await runGit(
["restore", `--source=${repo.base}`, "--worktree", "--", entry.path],
repo.root,
);
if (restored.ok) result.restored += 1;
else result.failures.push({ path: entry.path, reason: restored.stderr.trim() || "git restore failed" });
continue;
}
if (entry.kind === "delete") {
const abs = resolveInsideRepo(repo.root, entry.path);
if (!abs) {
result.failures.push({ path: entry.path, reason: "path escapes the repository root" });
continue;
}
try {
if (!fs.existsSync(abs)) continue; // already gone
fs.rmSync(abs, { force: true });
pruneEmptyParents(path.dirname(abs), repo.root);
result.deleted += 1;
} catch (error) {
result.failures.push({ path: entry.path, reason: messageOf(error) });
}
}
}
results.push(result);
}
return results;
}
/** Remove directories left empty by the rollback, stopping at the first non-empty one. */
function pruneEmptyParents(start: string, root: string): void {
let dir = start;
while (dir !== root && dir.startsWith(root + path.sep)) {
try {
if (fs.readdirSync(dir).length > 0) return;
fs.rmdirSync(dir);
} catch {
return;
}
dir = path.dirname(dir);
}
}
/** Compact human-readable rollback result, including the backup location. */
export function formatRollbackReport(
plan: RollbackPlan,
results: readonly RollbackRepoResult[],
backupDir: string | null,
): string {
const restored = results.reduce((sum, result) => sum + result.restored, 0);
const deleted = results.reduce((sum, result) => sum + result.deleted, 0);
const failures = results.flatMap((result) => result.failures.map((failure) => ({ root: result.root, ...failure })));
const unrecoverable = results.flatMap((result) => result.unrecoverable);
const headMoved = results.flatMap((result) => result.headMoved);
const lines: string[] = [
`Rolled back ${restored} file(s) and deleted ${deleted} file(s) created during this goal.`,
];
if (plan.repos.length > 1) {
for (const result of results) {
lines.push(` ${result.root}: restored ${result.restored}, deleted ${result.deleted}`);
}
}
if (failures.length > 0) {
lines.push(`${failures.length} path(s) could not be rolled back:`);
for (const failure of failures) lines.push(` ${failure.root} ${failure.path} — ${failure.reason}`);
}
if (unrecoverable.length > 0) {
lines.push(`${unrecoverable.length} change(s) were left in place (not restorable from the baseline):`);
for (const entry of unrecoverable) lines.push(` ${entry.path} — ${entry.reason}`);
}
if (headMoved.length > 0) {
lines.push(`${headMoved.length} repository/repositories had a moved HEAD and were not reset:`);
for (const entry of headMoved) lines.push(` ${entry.path} — ${entry.reason}`);
}
lines.push(backupDir ? `Backup (can be replayed with git apply): ${backupDir}` : "No backup was written.");
return lines.join("\n");
}