Extends FN-7554's single-repo PR revert path to workspace (multi-repo) tasks: when autoMerge is disabled, the revert route now opens one dedicated fusion/revert-<id> PR per sub-repo instead of refusing workspace tasks outright.
- Add prepareWorkspaceRevertPrBranches (packages/engine/src/task-revert.ts): classifies every sub-repo first and only prepares a per-sub-repo fusion/revert-<id> branch when all sub-repos are clean/already-reverted (all-or-nothing at branch-prep phase); never force-writes any sub-repo integration branch.
- Export the new helper from packages/engine/src/index.ts.
- Extend POST /api/tasks/:id/revert (register-task-workflow-routes.ts) to resolve owner/repo and check the GitHub rate limiter for every sub-repo before pushing/creating any PR, opening one PR per sub-repo and returning an additive { mode: "pr", clean: true, workspace: { repos: [...] } } result; degrades the whole task to needsHuman if GitHub is unconfigured or any sub-repo is rate-limited, rather than opening a partial subset of PRs.
- Leave existing { mode: "git" | "ai" | "pr" } shapes, the autoMerge:true workspace path, and FN-7554's single-repo PR path unchanged.
- Add engine real-git coverage (task-revert-workspace-pr.real-git.test.ts) and extend dashboard route tests (task-revert-route.test.ts) for the new workspace PR path.
- Add changeset (.changeset/fn-7577-workspace-pr-revert.md, minor) and update docs/task-management.md.
Files changed:
.changeset/fn-7577-workspace-pr-revert.md | 7 +
docs/task-management.md | 3 +-
.../src/__tests__/task-revert-route.test.ts | 313 ++++++++++++++++-
.../src/routes/register-task-workflow-routes.ts | 181 +++++++++-
.../task-revert-workspace-pr.real-git.test.ts | 371 +++++++++++++++++++++
packages/engine/src/index.ts | 4 +
packages/engine/src/task-revert.ts | 295 ++++++++++++++++
7 files changed, 1166 insertions(+), 8 deletions(-)
Fusion-Task-Id: FN-7577
Fusion-Task-Lineage: bedbfab7-5804-485f-9b40-64531edfc64a
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
1653 lines
78 KiB
TypeScript
1653 lines
78 KiB
TypeScript
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00:
|
|
* Intelligent git-revert service (FN-7523, foundation for FN-7501). Given a
|
|
* done/archived task, this module:
|
|
* 1. Resolves the set of commits attributable to that task (squash / rebase
|
|
* / lineage-snapshot precedence — see `resolveTaskRevertCommits`).
|
|
* 2. Performs a NON-committing dry-run revert to classify the outcome as
|
|
* already-reverted / clean / conflicting (see `classifyTaskRevert`).
|
|
* 3. When clean, creates the real revert commit(s) with a `Fusion-Task-Id`
|
|
* trailer on the resolved base branch (see `performTaskRevert`).
|
|
*
|
|
* This is the git path ONLY. Conflicting reverts are handed back to the
|
|
* caller/UI unresolved — the AI-undo fallback is sibling task FN-7524, and the
|
|
* UI affordance is sibling task FN-7525.
|
|
*
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 — workspace/multi-repo support):
|
|
* Workspace tasks (`task.workspaceWorktrees` populated, see `isWorkspaceTask`)
|
|
* land squash commits across MULTIPLE sub-repo integration branches. This
|
|
* module reasons about the WHOLE task's revert as one ALL-OR-NOTHING unit:
|
|
* `resolveWorkspaceTaskRevertCommits` resolves per-repo attribution,
|
|
* `revertWorkspaceTask` dry-run classifies EVERY sub-repo first and only
|
|
* commits a revert on ANY sub-repo when EVERY acquired sub-repo classifies
|
|
* clean/already-reverted. If any sub-repo conflicts, NO sub-repo is committed
|
|
* and every touched sub-repo worktree is rolled back byte-identical to its
|
|
* pre-call state — see `revertWorkspaceTask`'s doc comment for the full
|
|
* contract. The single-repo path below (`resolveTaskRevertCommits` /
|
|
* `classifyTaskRevert` / `performTaskRevert`) is UNCHANGED and continues to
|
|
* serve non-workspace tasks; `revertWorkspaceTask` reuses its dry-run
|
|
* (`classifyTaskRevert`) and apply/commit (`applyAndCommitRevert`) machinery
|
|
* per sub-repo rather than duplicating it. `granularity` (FN-7548, below)
|
|
* only applies to the single-repo path.
|
|
*
|
|
* Safety invariant (the core contract of this module): the working tree and
|
|
* index are NEVER left dirty on any failure path. `classifyTaskRevert` always
|
|
* captures `preRevertHead` before touching the tree and guarantees a full
|
|
* `git revert --abort` + `git reset --hard <preRevertHead>` rollback in a
|
|
* `finally` block, regardless of how the dry-run terminates.
|
|
*
|
|
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — PR-based revert extended to
|
|
* workspace tasks): `prepareRevertPrBranch` (FN-7554) explicitly refuses
|
|
* workspace tasks (`workspace-task-pr-revert-unsupported`) because a single
|
|
* PR against a single base branch cannot represent a multi-repo revert.
|
|
* `prepareWorkspaceRevertPrBranches` fills that gap: it mirrors
|
|
* `revertWorkspaceTask`'s classify-all-then-commit-all skeleton but, instead
|
|
* of force-committing onto each sub-repo's integration branch, prepares one
|
|
* dedicated `<revertBranch>` per sub-repo (never writing any integration
|
|
* branch) so the caller can open one PR per sub-repo. See its own doc
|
|
* comment below for the full contract.
|
|
*/
|
|
import { exec } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { isWorkspaceTask, type Task, type TaskCommitAssociation, type TaskCreateInput } from "@fusion/core";
|
|
import { collectOwnTaskCommitsForRange } from "./branch-attribution.js";
|
|
import { resolveIntegrationBranch, type IntegrationBranchSettings } from "./integration-branch.js";
|
|
|
|
const defaultExecAsync = promisify(exec);
|
|
type ExecAsyncImpl = typeof defaultExecAsync;
|
|
|
|
const GIT_TIMEOUT_MS = 30_000;
|
|
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
|
|
/** Minimal store surface this module depends on — keeps task-revert.ts test-friendly without pulling in the full TaskStore type. */
|
|
export interface TaskCommitAssociationSource {
|
|
getTaskCommitAssociationsByLineageId(lineageId: string): Promise<TaskCommitAssociation[]>;
|
|
}
|
|
|
|
export class TaskRevertError extends Error {
|
|
readonly code: string;
|
|
readonly cause?: unknown;
|
|
|
|
constructor(message: string, code: string, cause?: unknown) {
|
|
super(message);
|
|
this.name = "TaskRevertError";
|
|
this.code = code;
|
|
this.cause = cause;
|
|
}
|
|
}
|
|
|
|
function quoteShellArg(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
// FNXC:TaskRevert 2026-07-04-00:00:
|
|
// Reuse branch-attribution.ts's trailer/subject parsing so revert attribution
|
|
// stays consistent with merge-time attribution. Duplicated locally (rather
|
|
// than imported) because branch-attribution.ts's helpers are module-private;
|
|
// the regex/precedence MUST stay identical to branch-attribution.ts's
|
|
// `extractAttributedTaskId` / `extractTaskIdFromSubject` — update both together.
|
|
function extractAttributedTaskId(body: string): string | null {
|
|
const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim;
|
|
let match: RegExpExecArray | null = null;
|
|
let last: RegExpExecArray | null = null;
|
|
while (true) {
|
|
match = trailerPattern.exec(body);
|
|
if (!match) break;
|
|
last = match;
|
|
}
|
|
return last?.[1] ?? null;
|
|
}
|
|
|
|
function extractTaskIdFromSubject(subject: string): string | null {
|
|
if (!subject) return null;
|
|
const conventional =
|
|
/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject);
|
|
if (conventional?.[1]) return conventional[1].toUpperCase();
|
|
const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject);
|
|
if (bracketed?.[1]) return bracketed[1].toUpperCase();
|
|
const colon = /^\s*([A-Z]+-\d+):/i.exec(subject);
|
|
if (colon?.[1]) return colon[1].toUpperCase();
|
|
return null;
|
|
}
|
|
|
|
function taskIdsMatch(a: string | null, b: string): boolean {
|
|
if (!a) return false;
|
|
return a.toUpperCase() === b.toUpperCase();
|
|
}
|
|
|
|
async function runGit(
|
|
execImpl: ExecAsyncImpl,
|
|
command: string,
|
|
cwd: string,
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const result = await execImpl(command, {
|
|
cwd,
|
|
encoding: "utf-8",
|
|
timeout: GIT_TIMEOUT_MS,
|
|
maxBuffer: GIT_MAX_BUFFER,
|
|
});
|
|
return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") };
|
|
}
|
|
|
|
export type TaskRevertCommitSource = "squash" | "rebase" | "lineage" | "none";
|
|
|
|
export interface ResolvedTaskRevertCommits {
|
|
supported: true;
|
|
/** Attributable commit SHAs, newest first — reverting in this order applies the oldest change last, avoiding unnecessary self-conflicts among a task's own commits. */
|
|
shas: string[];
|
|
source: TaskRevertCommitSource;
|
|
}
|
|
|
|
export interface UnsupportedTaskRevert {
|
|
supported: false;
|
|
reason: string;
|
|
}
|
|
|
|
export interface ResolveTaskRevertCommitsOptions {
|
|
worktreePath: string;
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
/** Lineage-snapshot fallback source (typically the scoped TaskStore). Optional so callers that already know mergeDetails is present can omit it. */
|
|
commitAssociationSource?: TaskCommitAssociationSource;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00:
|
|
* Attribution precedence (mirrors merge-time attribution capture):
|
|
* 1. Squash strategy — `mergeDetails.commitSha` alone when `rebaseBaseSha`
|
|
* is unset (single squash commit landed the task).
|
|
* 2. Rebase/cherry-pick strategy — the task-attributable subset of
|
|
* `rebaseBaseSha..commitSha`, filtered by `Fusion-Task-Id` trailer or
|
|
* conventional-commit subject. Falls back to the full range endpoint
|
|
* (`commitSha`) when no per-commit attribution is possible (foreign
|
|
* commits interleaved with no trailer/subject match — better to revert
|
|
* the endpoint than nothing).
|
|
* 3. Lineage snapshot fallback — `TaskCommitAssociation` rows keyed by
|
|
* `taskLineageId`, used when `mergeDetails` is absent/incomplete (e.g.
|
|
* legacy tasks merged before mergeDetails was captured).
|
|
*
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (workspace limitation):
|
|
* Workspace tasks (`mergeDetails.workspaceLandedShas` present) land commits
|
|
* across MULTIPLE sub-repo integration branches with no single coherent
|
|
* revert target — reverting one sub-repo's commit without reasoning about the
|
|
* others could leave the workspace in a half-reverted, inconsistent state.
|
|
* This is explicitly out of scope for FN-7523; the caller should route
|
|
* workspace tasks to the AI-undo fallback (FN-7524) instead.
|
|
*/
|
|
export async function resolveTaskRevertCommits(
|
|
task: Pick<Task, "id" | "lineageId" | "mergeDetails">,
|
|
opts: ResolveTaskRevertCommitsOptions,
|
|
): Promise<ResolvedTaskRevertCommits | UnsupportedTaskRevert> {
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
const mergeDetails = task.mergeDetails;
|
|
|
|
if (mergeDetails?.workspaceLandedShas && Object.keys(mergeDetails.workspaceLandedShas).length > 0) {
|
|
return { supported: false, reason: "workspace-task-revert-unsupported" };
|
|
}
|
|
|
|
if (mergeDetails?.commitSha) {
|
|
if (!mergeDetails.rebaseBaseSha) {
|
|
// Squash strategy: the single recorded commit is the entire landed change.
|
|
return { supported: true, shas: [mergeDetails.commitSha], source: "squash" };
|
|
}
|
|
|
|
// Rebase/cherry-pick strategy: filter the range to this task's own commits.
|
|
const rangeRef = `${mergeDetails.rebaseBaseSha}..${mergeDetails.commitSha}`;
|
|
let logOutput: string;
|
|
try {
|
|
const { stdout } = await runGit(
|
|
execImpl,
|
|
`git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(rangeRef)}`,
|
|
opts.worktreePath,
|
|
);
|
|
logOutput = stdout;
|
|
} catch (error) {
|
|
throw new TaskRevertError(`git log failed for range ${rangeRef}`, "git-log-failed", error);
|
|
}
|
|
|
|
const ownCommitShas: string[] = [];
|
|
const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean);
|
|
for (const record of records) {
|
|
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
|
|
if (!sha) continue;
|
|
const body = bodyParts.join("\x00");
|
|
const trailerAttributedTaskId = extractAttributedTaskId(body);
|
|
const attributedTaskId = trailerAttributedTaskId ?? extractTaskIdFromSubject(subject);
|
|
if (taskIdsMatch(attributedTaskId, task.id)) {
|
|
ownCommitShas.push(sha);
|
|
}
|
|
}
|
|
|
|
if (ownCommitShas.length > 0) {
|
|
// `git log` without `--reverse` already yields newest-first order.
|
|
return { supported: true, shas: ownCommitShas, source: "rebase" };
|
|
}
|
|
|
|
// No per-commit attribution possible (foreign commits interleaved with no
|
|
// trailer/subject match) — fall back to reverting the full range endpoint.
|
|
return { supported: true, shas: [mergeDetails.commitSha], source: "rebase" };
|
|
}
|
|
|
|
// mergeDetails absent/incomplete — fall back to the lineage-snapshot association table.
|
|
const lineageId = task.lineageId ?? task.id;
|
|
if (!opts.commitAssociationSource) {
|
|
return { supported: true, shas: [], source: "none" };
|
|
}
|
|
const associations = await opts.commitAssociationSource.getTaskCommitAssociationsByLineageId(lineageId);
|
|
if (associations.length === 0) {
|
|
return { supported: true, shas: [], source: "none" };
|
|
}
|
|
// Rows are already ordered `authoredAt DESC, createdAt DESC` (newest first) by the store query.
|
|
return { supported: true, shas: associations.map((a) => a.commitSha), source: "lineage" };
|
|
}
|
|
|
|
export type TaskRevertClassification = "already-reverted" | "clean" | "conflicting";
|
|
|
|
export interface TaskRevertConflict {
|
|
file: string;
|
|
/** Raw `git status --porcelain` two-letter status code for the conflicted file (e.g. "UU", "AA"). */
|
|
status?: string;
|
|
}
|
|
|
|
export interface ClassifyTaskRevertResult {
|
|
classification: TaskRevertClassification;
|
|
conflicts?: TaskRevertConflict[];
|
|
alreadyReverted?: boolean;
|
|
}
|
|
|
|
export interface ClassifyTaskRevertOptions {
|
|
worktreePath: string;
|
|
/** Attributable commit SHAs, newest first (see `resolveTaskRevertCommits`). */
|
|
commits: string[];
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
}
|
|
|
|
async function getUnmergedFiles(
|
|
execImpl: ExecAsyncImpl,
|
|
worktreePath: string,
|
|
): Promise<TaskRevertConflict[]> {
|
|
const { stdout } = await runGit(execImpl, "git status --porcelain", worktreePath);
|
|
const conflicts: TaskRevertConflict[] = [];
|
|
for (const line of stdout.split("\n")) {
|
|
if (!line.trim()) continue;
|
|
const status = line.slice(0, 2);
|
|
// Unmerged states per `git status --porcelain`: UU, AA, DD, AU, UA, UD, DU.
|
|
if (/^(UU|AA|DD|AU|UA|UD|DU)$/.test(status)) {
|
|
conflicts.push({ file: line.slice(3).trim(), status });
|
|
}
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00:
|
|
* Rollback safety contract (the core invariant of this service): capture
|
|
* `preRevertHead` BEFORE any git mutation. If the working tree is dirty at
|
|
* entry, refuse immediately without touching anything. Otherwise, run the
|
|
* dry-run revert sequence; regardless of outcome (clean, conflicting, or an
|
|
* unexpected error), the `finally` block runs `git revert --abort`
|
|
* (best-effort) THEN `git reset --hard <preRevertHead>` so the tree/index are
|
|
* byte-identical to the pre-call state. This function NEVER commits and NEVER
|
|
* throws without first completing that rollback.
|
|
*/
|
|
export async function classifyTaskRevert(opts: ClassifyTaskRevertOptions): Promise<ClassifyTaskRevertResult> {
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
const { worktreePath, commits } = opts;
|
|
|
|
if (commits.length === 0) {
|
|
return { classification: "already-reverted", alreadyReverted: true };
|
|
}
|
|
|
|
let preRevertHead: string;
|
|
try {
|
|
const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
|
preRevertHead = stdout.trim();
|
|
} catch (error) {
|
|
throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed", error);
|
|
}
|
|
if (!preRevertHead) {
|
|
throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed");
|
|
}
|
|
|
|
const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", worktreePath);
|
|
if (statusOut.trim().length > 0) {
|
|
throw new TaskRevertError(
|
|
"working tree is dirty; refusing to attempt a revert dry-run",
|
|
"dirty-working-tree",
|
|
);
|
|
}
|
|
|
|
let mutated = false;
|
|
let allAlreadyReverted = true;
|
|
const conflicts: TaskRevertConflict[] = [];
|
|
|
|
try {
|
|
for (const sha of commits) {
|
|
mutated = true;
|
|
const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
|
|
try {
|
|
await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath);
|
|
// FNXC:TaskRevert 2026-07-04-00:00: `git revert --no-commit` on an
|
|
// already-reverted commit exits 0 with NO staged/working-tree diff
|
|
// (no error, no "nothing to commit" text — that message only ever
|
|
// appears from a *subsequent* `git commit` attempt). Detect this by
|
|
// diffing `git status --porcelain` before/after the call: if nothing
|
|
// changed, this sha is a no-op; `--quit` clears the sequencer's
|
|
// in-progress marker WITHOUT touching any diff staged by earlier shas
|
|
// in this same batch (unlike `--abort`, which would reset everything).
|
|
const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
|
|
if (statusAfter === statusBefore) {
|
|
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
|
|
continue;
|
|
}
|
|
allAlreadyReverted = false;
|
|
} catch (error) {
|
|
const stderr =
|
|
typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string"
|
|
? (error as { stderr: string }).stderr
|
|
: "";
|
|
const stdout =
|
|
typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string"
|
|
? (error as { stdout: string }).stdout
|
|
: "";
|
|
const combined = `${stdout}\n${stderr}`;
|
|
const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath);
|
|
if (unmergedFiles.length > 0) {
|
|
conflicts.push(...unmergedFiles);
|
|
allAlreadyReverted = false;
|
|
break;
|
|
}
|
|
// "nothing to commit" / empty-revert signal: this commit's change is
|
|
// already reflected as reverted at HEAD — treat as a no-op and continue.
|
|
if (/nothing to commit|no changes|empty commit/i.test(combined)) {
|
|
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
|
|
continue;
|
|
}
|
|
throw new TaskRevertError(`git revert --no-commit failed unexpectedly for ${sha}`, "revert-dry-run-failed", error);
|
|
}
|
|
}
|
|
} finally {
|
|
if (mutated) {
|
|
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
if (conflicts.length > 0) {
|
|
return { classification: "conflicting", conflicts };
|
|
}
|
|
if (allAlreadyReverted) {
|
|
return { classification: "already-reverted", alreadyReverted: true };
|
|
}
|
|
return { classification: "clean" };
|
|
}
|
|
|
|
// FNXC:TaskRevert 2026-07-04-12:00 (shared per-sha apply primitive, FN-7548):
|
|
// factors the `git revert --no-commit` + status-diff no-op detection +
|
|
// unmerged-file conflict detection used by BOTH performTaskRevert apply paths
|
|
// (squash and per-sha) into one place. Returns a discriminated outcome
|
|
// instead of committing or rolling back itself — callers own the
|
|
// commit/rollback decision (squash accumulates across shas before
|
|
// committing once; per-sha commits after each staged sha).
|
|
type RevertShaApplyOutcome =
|
|
| { kind: "staged" }
|
|
| { kind: "noop" }
|
|
| { kind: "conflict"; conflicts: TaskRevertConflict[] };
|
|
|
|
async function applyRevertNoCommit(
|
|
execImpl: ExecAsyncImpl,
|
|
worktreePath: string,
|
|
sha: string,
|
|
): Promise<RevertShaApplyOutcome> {
|
|
const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
|
|
try {
|
|
await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath);
|
|
// FNXC:TaskRevert 2026-07-04-00:00: `git revert --no-commit` on an
|
|
// already-reverted commit exits 0 with no staged/working-tree diff (no
|
|
// thrown error, no "nothing to commit" text on this call). Detect this by
|
|
// diffing `git status --porcelain` before/after: if unchanged, this sha is
|
|
// a no-op; `--quit` clears the sequencer's in-progress marker WITHOUT
|
|
// touching any diff staged by earlier shas in this same batch.
|
|
const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
|
|
if (statusAfter === statusBefore) {
|
|
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
|
|
return { kind: "noop" };
|
|
}
|
|
return { kind: "staged" };
|
|
} catch (error) {
|
|
const stderr =
|
|
typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string"
|
|
? (error as { stderr: string }).stderr
|
|
: "";
|
|
const stdout =
|
|
typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string"
|
|
? (error as { stdout: string }).stdout
|
|
: "";
|
|
if (/nothing to commit|no changes|empty commit/i.test(`${stdout}\n${stderr}`)) {
|
|
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
|
|
return { kind: "noop" };
|
|
}
|
|
const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath);
|
|
return { kind: "conflict", conflicts: unmergedFiles };
|
|
}
|
|
}
|
|
|
|
function deriveShortSummary(originalSubject: string): string {
|
|
return (
|
|
originalSubject
|
|
.replace(/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style)\([^)]*\):\s*/i, "")
|
|
.slice(0, 72) || "revert landed changes"
|
|
);
|
|
}
|
|
|
|
export type TaskRevertResult =
|
|
| { mode: "git"; clean: true; revertCommitSha: string; revertCommitShas: string[] }
|
|
| { mode: "git"; clean: true; alreadyReverted: true }
|
|
| { mode: "git"; clean: false; conflicts: TaskRevertConflict[] }
|
|
| { mode: "git"; unsupported: true; reason: string }
|
|
| { mode: "git"; needsHuman: true; reason: string };
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-12:00 (granularity, FN-7548):
|
|
* `"squash"` (default, unchanged FN-7523 behavior) accumulates every
|
|
* attributable sha into ONE final revert commit. `"per-sha"` creates one
|
|
* attributed `revert(FN-xxxx): ...` commit PER non-no-op original sha, each
|
|
* with its own `Fusion-Task-Id` trailer and an audit line referencing that
|
|
* specific sha — giving finer-grained audit trail / rollback (an operator
|
|
* can drop a single per-sha revert without unwinding the whole task). A
|
|
* mid-batch conflict in EITHER mode rolls the whole batch back to
|
|
* `preRevertHead` — partially-landed per-commit reverts are never left on
|
|
* disk (see the shared `mutated`/`preRevertHead` rollback in the outer
|
|
* catch, and the inline abort+reset on conflict below).
|
|
*/
|
|
export type TaskRevertGranularity = "squash" | "per-sha";
|
|
|
|
export interface PerformTaskRevertOptions {
|
|
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "autoMerge" | "userPaused" | "paused" | "workspaceWorktrees">;
|
|
worktreePath: string;
|
|
baseBranch: string;
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
commitAssociationSource?: TaskCommitAssociationSource;
|
|
/** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */
|
|
effectiveAutoMerge?: boolean;
|
|
/** Commit granularity for the real (committing) revert. Defaults to `"squash"` — omitting this option preserves FN-7523 behavior exactly. */
|
|
granularity?: TaskRevertGranularity;
|
|
}
|
|
|
|
// FNXC:TaskRevert 2026-07-04-00:00 (guard rails, enforced in BOTH the service
|
|
// and the route per PROMPT Step 3): only done/archived tasks are revertable.
|
|
const REVERTABLE_COLUMNS = new Set(["done", "archived"]);
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (commit message/trailer contract):
|
|
* The revert commit's subject is `revert(FN-xxxx): <short summary>` (the
|
|
* original task's id + a short summary derived from the reverted commit's
|
|
* subject), with a body carrying `Fusion-Task-Id: FN-xxxx` (the ORIGINAL
|
|
* task id, so `extractAttributedTaskId` continues to resolve attribution back
|
|
* to the reverted task) and a `Reverts work landed by task FN-xxxx (<sha>).`
|
|
* line for human audit. This mirrors the commit-message conventions in
|
|
* AGENTS.md (task-id-prefixed subjects, `Fusion-Task-Id` trailer).
|
|
*
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (guard rails):
|
|
* - Only `done`/`archived` tasks may be reverted (checked here AND at the API
|
|
* route layer — defense in depth).
|
|
* - When `autoMerge` is effectively off for this task, this function refuses
|
|
* with a `needsHuman` result instead of force-writing a revert commit onto
|
|
* a branch the project has opted out of automated writes to.
|
|
* - This function NEVER mutates the source task's store row/column — reverting
|
|
* is a forward-only git operation on the base branch, not a lifecycle move.
|
|
*/
|
|
export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise<TaskRevertResult> {
|
|
// FNXC:TaskRevert 2026-07-04-00:00: `baseBranch` is part of the stable
|
|
// caller-facing contract (the route resolves it via mergeTargetBranch /
|
|
// the integration-branch resolver) but is not read here directly — the
|
|
// caller is responsible for ensuring `worktreePath` is checked out at that
|
|
// branch's HEAD before invoking this function; kept as a named, documented
|
|
// parameter (not silently dropped) for FN-7524/FN-7525 call-site clarity.
|
|
const { task, worktreePath, baseBranch: _baseBranch } = opts;
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
|
|
// FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 dispatch guard): this function
|
|
// is the single-repo entry point ONLY. Workspace tasks (`isWorkspaceTask`)
|
|
// must be routed by the caller to `revertWorkspaceTask` instead — refuse
|
|
// explicitly here (rather than silently reverting one arbitrary sub-repo)
|
|
// so a caller that forgets to check `isWorkspaceTask` first gets a clear
|
|
// signal instead of a half-coherent single-repo revert.
|
|
if (isWorkspaceTask(task)) {
|
|
return { mode: "git", unsupported: true, reason: "workspace-task-revert-unsupported-by-single-repo-path; use revertWorkspaceTask" };
|
|
}
|
|
|
|
if (!REVERTABLE_COLUMNS.has(task.column)) {
|
|
return { mode: "git", needsHuman: true, reason: `task is in column "${task.column}"; only done/archived tasks are revertable` };
|
|
}
|
|
|
|
const effectiveAutoMerge = task.autoMerge ?? opts.effectiveAutoMerge ?? true;
|
|
if (effectiveAutoMerge === false) {
|
|
return { mode: "git", needsHuman: true, reason: "autoMerge is disabled for this task/project; refusing to force-write a revert commit" };
|
|
}
|
|
|
|
const resolved = await resolveTaskRevertCommits(task, {
|
|
worktreePath,
|
|
execAsyncImpl: execImpl,
|
|
commitAssociationSource: opts.commitAssociationSource,
|
|
});
|
|
if (!resolved.supported) {
|
|
return { mode: "git", unsupported: true, reason: resolved.reason };
|
|
}
|
|
|
|
const classification = await classifyTaskRevert({
|
|
worktreePath,
|
|
commits: resolved.shas,
|
|
execAsyncImpl: execImpl,
|
|
});
|
|
|
|
if (classification.classification === "already-reverted") {
|
|
return { mode: "git", clean: true, alreadyReverted: true };
|
|
}
|
|
if (classification.classification === "conflicting") {
|
|
return { mode: "git", clean: false, conflicts: classification.conflicts ?? [] };
|
|
}
|
|
|
|
// classification === "clean" — perform the real (committing) revert.
|
|
let preRevertHead: string;
|
|
try {
|
|
const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
|
preRevertHead = stdout.trim();
|
|
} catch (error) {
|
|
throw new TaskRevertError("failed to resolve HEAD before applying revert", "head-resolve-failed", error);
|
|
}
|
|
|
|
const granularity: TaskRevertGranularity = opts.granularity ?? "squash";
|
|
let mutated = false;
|
|
try {
|
|
if (granularity === "per-sha") {
|
|
// FNXC:TaskRevert 2026-07-04-12:00 (per-commit apply path, FN-7548):
|
|
// stage-and-commit ONE sha at a time so each attributable original sha
|
|
// gets its own attributed revert commit. No-op shas (already reverted
|
|
// at HEAD) are skipped without creating an empty commit. A conflict on
|
|
// any sha rolls the ENTIRE batch back to preRevertHead — there is no
|
|
// partially-landed per-commit state.
|
|
const createdCommitShas: string[] = [];
|
|
for (const sha of resolved.shas) {
|
|
mutated = true;
|
|
const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha);
|
|
if (outcome.kind === "conflict") {
|
|
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
|
return { mode: "git", clean: false, conflicts: outcome.conflicts };
|
|
}
|
|
if (outcome.kind === "noop") continue;
|
|
|
|
let originalSubject = "";
|
|
try {
|
|
const { stdout } = await runGit(execImpl, `git log -1 --format=%s ${quoteShellArg(sha)}`, worktreePath);
|
|
originalSubject = stdout.trim();
|
|
} catch {
|
|
originalSubject = "";
|
|
}
|
|
const shortSummary = deriveShortSummary(originalSubject);
|
|
const subject = `revert(${task.id}): ${shortSummary}`;
|
|
const body1 = `Fusion-Task-Id: ${task.id}`;
|
|
const body2 = `Reverts ${originalSubject || sha} @ ${sha.slice(0, 8)}.`;
|
|
|
|
await runGit(
|
|
execImpl,
|
|
`git commit -m ${quoteShellArg(subject)} -m ${quoteShellArg(body1)} -m ${quoteShellArg(body2)}`,
|
|
worktreePath,
|
|
);
|
|
const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
|
createdCommitShas.push(newHead.trim());
|
|
}
|
|
|
|
if (createdCommitShas.length === 0) {
|
|
// Defensive: every sha in this batch turned out to be a no-op during the
|
|
// apply pass even though classify saw at least one real change (branch
|
|
// moved between classify and apply, or a race). Nothing to commit —
|
|
// report already-reverted rather than attempting an empty commit.
|
|
return { mode: "git", clean: true, alreadyReverted: true };
|
|
}
|
|
return {
|
|
mode: "git",
|
|
clean: true,
|
|
revertCommitSha: createdCommitShas[0]!,
|
|
revertCommitShas: createdCommitShas,
|
|
};
|
|
}
|
|
|
|
// granularity === "squash" (default, byte-for-byte unchanged FN-7523 behavior):
|
|
// accumulate every attributable sha via `git revert --no-commit`, then create
|
|
// ONE final commit spanning the whole batch.
|
|
let anyStaged = false;
|
|
for (const sha of resolved.shas) {
|
|
mutated = true;
|
|
const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha);
|
|
if (outcome.kind === "conflict") {
|
|
// The dry-run already proved this is clean; a live conflict here means
|
|
// the branch moved between classify and apply. Roll back and report conflicting.
|
|
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
|
return { mode: "git", clean: false, conflicts: outcome.conflicts };
|
|
}
|
|
if (outcome.kind === "staged") anyStaged = true;
|
|
}
|
|
|
|
if (!anyStaged) {
|
|
// Defensive: every sha in this batch turned out to be a no-op during the
|
|
// apply pass even though classify saw at least one real change (branch
|
|
// moved between classify and apply, or a race). Nothing to commit —
|
|
// report already-reverted rather than attempting an empty commit.
|
|
return { mode: "git", clean: true, alreadyReverted: true };
|
|
}
|
|
|
|
let originalSubject = "";
|
|
try {
|
|
const { stdout } = await runGit(execImpl, `git log -1 --format=%s ${quoteShellArg(resolved.shas[0] ?? "HEAD")}`, worktreePath);
|
|
originalSubject = stdout.trim();
|
|
} catch {
|
|
originalSubject = "";
|
|
}
|
|
|
|
const shortSummary = deriveShortSummary(originalSubject);
|
|
const subject = `revert(${task.id}): ${shortSummary}`;
|
|
const referencedSha = resolved.shas[0] ?? "unknown";
|
|
const body1 = `Fusion-Task-Id: ${task.id}`;
|
|
const body2 = `Reverts work landed by task ${task.id} (${originalSubject || referencedSha} @ ${referencedSha.slice(0, 8)}).`;
|
|
|
|
await runGit(
|
|
execImpl,
|
|
`git commit -m ${quoteShellArg(subject)} -m ${quoteShellArg(body1)} -m ${quoteShellArg(body2)}`,
|
|
worktreePath,
|
|
);
|
|
|
|
const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
|
const revertCommitSha = newHead.trim();
|
|
return { mode: "git", clean: true, revertCommitSha, revertCommitShas: [revertCommitSha] };
|
|
} catch (error) {
|
|
if (mutated) {
|
|
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
|
}
|
|
throw error instanceof TaskRevertError ? error : new TaskRevertError("failed to apply revert commit", "revert-apply-failed", error);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FN-7547: workspace/multi-repo task revert support.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (extracted for FN-7547 reuse):
|
|
* The squash-granularity apply+commit phase, factored out so the workspace
|
|
* multi-repo path (`revertWorkspaceTask`) can run the IDENTICAL single-repo
|
|
* apply/commit machinery once per sub-repo, keeping the commit message/trailer
|
|
* contract and the live-conflict-during-apply rollback identical between the
|
|
* single-repo and workspace paths. Built on the same `applyRevertNoCommit`
|
|
* shared primitive `performTaskRevert`'s squash branch uses above — it is NOT
|
|
* a second reimplementation. Callers MUST have already run `classifyTaskRevert`
|
|
* and confirmed "clean" for these `commits` — this function does not
|
|
* re-classify; it assumes the dry-run already proved the revert applies
|
|
* cleanly and only guards against the branch moving between classify and
|
|
* apply (a live conflict here rolls back THIS repo/worktree only — the caller
|
|
* is responsible for any cross-repo rollback in the workspace all-or-nothing
|
|
* contract). `granularity` (FN-7548) does not apply to the workspace path;
|
|
* this always produces one commit per sub-repo.
|
|
*/
|
|
async function applyAndCommitRevert(opts: {
|
|
worktreePath: string;
|
|
/** Attributable commit SHAs, newest first. */
|
|
commits: string[];
|
|
taskId: string;
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
}): Promise<
|
|
| { applied: true; revertCommitSha: string }
|
|
| { applied: false; alreadyReverted: true }
|
|
| { applied: false; conflicts: TaskRevertConflict[] }
|
|
> {
|
|
const { worktreePath, commits, taskId } = opts;
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
|
|
let preRevertHead: string;
|
|
try {
|
|
const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
|
preRevertHead = stdout.trim();
|
|
} catch (error) {
|
|
throw new TaskRevertError("failed to resolve HEAD before applying revert", "head-resolve-failed", error);
|
|
}
|
|
|
|
let mutated = false;
|
|
let anyStaged = false;
|
|
try {
|
|
for (const sha of commits) {
|
|
mutated = true;
|
|
const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha);
|
|
if (outcome.kind === "conflict") {
|
|
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
|
return { applied: false, conflicts: outcome.conflicts };
|
|
}
|
|
if (outcome.kind === "staged") anyStaged = true;
|
|
}
|
|
|
|
if (!anyStaged) {
|
|
// Defensive: every sha in this batch turned out to be a no-op during the
|
|
// apply pass even though classify saw at least one real change (branch
|
|
// moved between classify and apply, or a race). Nothing to commit —
|
|
// report already-reverted rather than attempting an empty commit.
|
|
return { applied: false, alreadyReverted: true };
|
|
}
|
|
|
|
let originalSubject = "";
|
|
try {
|
|
const { stdout } = await runGit(execImpl, `git log -1 --format=%s ${quoteShellArg(commits[0] ?? "HEAD")}`, worktreePath);
|
|
originalSubject = stdout.trim();
|
|
} catch {
|
|
originalSubject = "";
|
|
}
|
|
|
|
const shortSummary = deriveShortSummary(originalSubject);
|
|
const subject = `revert(${taskId}): ${shortSummary}`;
|
|
const referencedSha = commits[0] ?? "unknown";
|
|
const body1 = `Fusion-Task-Id: ${taskId}`;
|
|
const body2 = `Reverts work landed by task ${taskId} (${originalSubject || referencedSha} @ ${referencedSha.slice(0, 8)}).`;
|
|
|
|
await runGit(
|
|
execImpl,
|
|
`git commit -m ${quoteShellArg(subject)} -m ${quoteShellArg(body1)} -m ${quoteShellArg(body2)}`,
|
|
worktreePath,
|
|
);
|
|
|
|
const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
|
return { applied: true, revertCommitSha: newHead.trim() };
|
|
} catch (error) {
|
|
if (mutated) {
|
|
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
|
}
|
|
throw error instanceof TaskRevertError ? error : new TaskRevertError("failed to apply revert commit", "revert-apply-failed", error);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FN-7554: PR-based revert for autoMerge:false projects.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type PrepareRevertPrBranchResult =
|
|
| { eligible: true; revertBranch: string; revertCommitShas: string[] }
|
|
| { eligible: false; classification: "conflicting"; conflicts: TaskRevertConflict[] }
|
|
| { eligible: false; classification: "already-reverted"; alreadyReverted: true }
|
|
| { eligible: false; unsupported: true; reason: string };
|
|
|
|
export interface PrepareRevertPrBranchOptions {
|
|
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "workspaceWorktrees">;
|
|
/** The shared checkout, verified on `baseBranch` by the caller before this is invoked. */
|
|
worktreePath: string;
|
|
/** Resolved mergeTargetBranch / integration branch. NEVER written to — see the doc comment below. */
|
|
baseBranch: string;
|
|
/** e.g. `fusion/revert-<task-id-lowercase>`. */
|
|
revertBranch: string;
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
commitAssociationSource?: TaskCommitAssociationSource;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7554 — PR-based revert for
|
|
* autoMerge:false projects):
|
|
*
|
|
* `performTaskRevert` refuses (`needsHuman`) whenever autoMerge is
|
|
* effectively off, because it commits directly onto `worktreePath`'s current
|
|
* HEAD branch (the base branch) and this project has opted that branch out
|
|
* of automated writes. This function gives that dead end an actionable path:
|
|
* it prepares a DEDICATED `revertBranch` off `baseBranch`'s HEAD, applies the
|
|
* revert commit(s) onto THAT branch only, and leaves `baseBranch` itself
|
|
* completely untouched — the caller (the API route) then pushes the branch
|
|
* and opens a real GitHub PR against `baseBranch`, so the change still lands
|
|
* through the project's normal human-review flow instead of a forced write.
|
|
*
|
|
* NEVER-WRITE-TO-BASE INVARIANT: this function only ever mutates
|
|
* `revertBranch`. `baseBranch`'s ref is never advanced, reset, or committed
|
|
* to. The shared checkout (`worktreePath`) is ALWAYS restored to the branch
|
|
* it was on when this function was called (`originalBranch`), in a `finally`
|
|
* — regardless of success, classification pass-through, or thrown failure —
|
|
* so a caller that shares this checkout across requests never observes it
|
|
* left mid-revert on `revertBranch`.
|
|
*
|
|
* REUSE, NOT REIMPLEMENTATION: commit application/message/trailer generation
|
|
* is delegated entirely to the shared `applyAndCommitRevert` helper (the same
|
|
* one `performTaskRevert`'s squash path and `revertWorkspaceTask` use) — this
|
|
* function only adds the branch-prep/checkout-restore choreography around it.
|
|
*
|
|
* WORKSPACE DEFERRAL: workspace (multi-repo) tasks are refused with
|
|
* `{ eligible: false, unsupported: true, reason: "workspace-task-pr-revert-unsupported" }`
|
|
* — a single PR against a single base branch cannot coherently represent a
|
|
* multi-repo, multi-branch revert. PR-based workspace revert is explicitly
|
|
* out of scope here (see the FN-7554 PROMPT's Step 6 follow-up task).
|
|
*/
|
|
export async function prepareRevertPrBranch(opts: PrepareRevertPrBranchOptions): Promise<PrepareRevertPrBranchResult> {
|
|
const { task, worktreePath, baseBranch, revertBranch } = opts;
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
|
|
if (isWorkspaceTask(task)) {
|
|
return { eligible: false, unsupported: true, reason: "workspace-task-pr-revert-unsupported" };
|
|
}
|
|
|
|
const resolved = await resolveTaskRevertCommits(task, {
|
|
worktreePath,
|
|
execAsyncImpl: execImpl,
|
|
commitAssociationSource: opts.commitAssociationSource,
|
|
});
|
|
if (!resolved.supported) {
|
|
return { eligible: false, unsupported: true, reason: resolved.reason };
|
|
}
|
|
|
|
const classification = await classifyTaskRevert({
|
|
worktreePath,
|
|
commits: resolved.shas,
|
|
execAsyncImpl: execImpl,
|
|
});
|
|
|
|
if (classification.classification === "already-reverted") {
|
|
return { eligible: false, classification: "already-reverted", alreadyReverted: true };
|
|
}
|
|
if (classification.classification === "conflicting") {
|
|
return { eligible: false, classification: "conflicting", conflicts: classification.conflicts ?? [] };
|
|
}
|
|
|
|
// classification === "clean" — prepare the dedicated revert branch.
|
|
let originalBranch: string;
|
|
try {
|
|
const { stdout } = await runGit(execImpl, "git rev-parse --abbrev-ref HEAD", worktreePath);
|
|
originalBranch = stdout.trim();
|
|
} catch (error) {
|
|
throw new TaskRevertError("failed to resolve current branch before preparing revert PR branch", "head-resolve-failed", error);
|
|
}
|
|
|
|
const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", worktreePath);
|
|
if (statusOut.trim().length > 0) {
|
|
throw new TaskRevertError(
|
|
"working tree is dirty; refusing to prepare a revert PR branch",
|
|
"dirty-working-tree",
|
|
);
|
|
}
|
|
|
|
let branchCreated = false;
|
|
try {
|
|
// FNXC:TaskRevert 2026-07-05-00:00: `-B` (create-or-reset) makes re-running
|
|
// this idempotent when a stale local `revertBranch` already exists from a
|
|
// prior failed/aborted attempt — it is reset off `baseBranch` HEAD rather
|
|
// than accumulating on top of whatever it previously pointed at. `baseBranch`
|
|
// itself is only ever read here (`git checkout -B <revertBranch> <baseBranch>`
|
|
// does not move `baseBranch`'s ref).
|
|
await runGit(
|
|
execImpl,
|
|
`git checkout -B ${quoteShellArg(revertBranch)} ${quoteShellArg(baseBranch)}`,
|
|
worktreePath,
|
|
);
|
|
branchCreated = true;
|
|
|
|
const applied = await applyAndCommitRevert({
|
|
worktreePath,
|
|
commits: resolved.shas,
|
|
taskId: task.id,
|
|
execAsyncImpl: execImpl,
|
|
});
|
|
|
|
if ("alreadyReverted" in applied) {
|
|
// Defensive: the branch moved between classify and apply. Nothing to
|
|
// commit on the fresh revertBranch — treat as already-reverted.
|
|
return { eligible: false, classification: "already-reverted", alreadyReverted: true };
|
|
}
|
|
if ("conflicts" in applied) {
|
|
// Late conflict — applyAndCommitRevert already rolled worktreePath back
|
|
// to the pre-apply HEAD (the tip of revertBranch, i.e. baseBranch's HEAD).
|
|
return { eligible: false, classification: "conflicting", conflicts: applied.conflicts };
|
|
}
|
|
|
|
return { eligible: true, revertBranch, revertCommitShas: [applied.revertCommitSha] };
|
|
} catch (error) {
|
|
// On any thrown failure after branch creation, never leave a dangling
|
|
// partial revert branch behind — best-effort restore + delete.
|
|
if (branchCreated) {
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(originalBranch)}`, worktreePath).catch(() => undefined);
|
|
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, worktreePath).catch(() => undefined);
|
|
}
|
|
throw error instanceof TaskRevertError ? error : new TaskRevertError("failed to prepare revert PR branch", "revert-pr-branch-prepare-failed", error);
|
|
} finally {
|
|
// FNXC:TaskRevert 2026-07-05-00:00: ALWAYS restore the shared checkout to
|
|
// the branch it was on when this function was called, so the checkout is
|
|
// left exactly where it started — on `baseBranch` (unmutated) in the
|
|
// documented caller contract — regardless of success/pass-through/failure
|
|
// above. The revert commit(s) live ONLY on `revertBranch`.
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(originalBranch)}`, worktreePath).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export interface WorkspaceRepoRevertCommits {
|
|
commits: string[];
|
|
source: TaskRevertCommitSource;
|
|
}
|
|
|
|
export interface ResolveWorkspaceTaskRevertCommitsOptions {
|
|
workspaceRootDir: string;
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
/** Lineage-snapshot fallback source (typically the scoped TaskStore). */
|
|
commitAssociationSource?: TaskCommitAssociationSource;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 per-repo attribution):
|
|
* Resolves the attributable commit(s) for EACH sub-repo of a workspace task,
|
|
* keyed by repo-relative path, iterating `Object.keys(task.workspaceWorktrees)
|
|
* .sort()` for the SAME deterministic order `landWorkspaceTask`/self-healing
|
|
* use (KTD1) — this determinism matters because Step 2/3's all-or-nothing
|
|
* commit ordering depends on a stable, reproducible repo iteration order.
|
|
*
|
|
* Precedence per sub-repo (mirrors the single-repo precedence in
|
|
* `resolveTaskRevertCommits`, adapted to the workspace land model where each
|
|
* sub-repo has its own representative squash sha rather than one task-wide
|
|
* `mergeDetails.commitSha`):
|
|
* 1. Squash — `mergeDetails.workspaceLandedShas[repoRel]` alone, when the
|
|
* sub-repo's `workspaceWorktrees[repoRel].baseCommitSha` is unset (the
|
|
* representative squash sha landed the entire sub-repo's contribution).
|
|
* 2. Rebase/range — when `baseCommitSha` IS set, filter the range
|
|
* `baseCommitSha..landedSha` to this task's own commits via
|
|
* `collectOwnTaskCommitsForRange` (shared with branch-attribution.ts),
|
|
* falling back to the range endpoint (`landedSha`) when no per-commit
|
|
* attribution is possible.
|
|
* 3. Lineage snapshot fallback — `TaskCommitAssociation` rows keyed by
|
|
* `taskLineageId`, filtered to commits that are reachable in THIS
|
|
* sub-repo (`git cat-file -e <sha>`) — a lineage snapshot is task-wide,
|
|
* not per-repo, so it must be filtered per sub-repo to avoid attributing
|
|
* another sub-repo's commit here.
|
|
*/
|
|
export async function resolveWorkspaceTaskRevertCommits(
|
|
task: Pick<Task, "id" | "lineageId" | "mergeDetails" | "workspaceWorktrees">,
|
|
opts: ResolveWorkspaceTaskRevertCommitsOptions,
|
|
): Promise<Record<string, WorkspaceRepoRevertCommits>> {
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
|
const repoKeys = Object.keys(workspaceWorktrees).sort();
|
|
const workspaceLandedShas = task.mergeDetails?.workspaceLandedShas ?? {};
|
|
|
|
const result: Record<string, WorkspaceRepoRevertCommits> = {};
|
|
|
|
for (const repoRel of repoKeys) {
|
|
const entry = workspaceWorktrees[repoRel];
|
|
const repoRootDir = join(opts.workspaceRootDir, repoRel);
|
|
const landedSha = workspaceLandedShas[repoRel] ?? entry?.landedSha;
|
|
|
|
if (landedSha && !entry?.baseCommitSha) {
|
|
result[repoRel] = { commits: [landedSha], source: "squash" };
|
|
continue;
|
|
}
|
|
|
|
if (landedSha && entry?.baseCommitSha) {
|
|
const rangeRef = `${entry.baseCommitSha}..${landedSha}`;
|
|
let ownCommitShas: string[];
|
|
try {
|
|
const collected = await collectOwnTaskCommitsForRange({
|
|
worktreePath: repoRootDir,
|
|
rangeRef,
|
|
taskId: task.id,
|
|
execAsyncImpl: execImpl,
|
|
});
|
|
ownCommitShas = collected.ownCommitShas;
|
|
} catch (error) {
|
|
throw new TaskRevertError(`git log failed for sub-repo ${repoRel} range ${rangeRef}`, "git-log-failed", error);
|
|
}
|
|
if (ownCommitShas.length > 0) {
|
|
result[repoRel] = { commits: ownCommitShas, source: "rebase" };
|
|
} else {
|
|
result[repoRel] = { commits: [landedSha], source: "rebase" };
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// No representative landed sha recorded for this sub-repo — fall back to
|
|
// the lineage-snapshot association table, filtered to commits reachable
|
|
// in THIS sub-repo (a lineage snapshot is task-wide, not per-repo).
|
|
const lineageId = task.lineageId ?? task.id;
|
|
if (!opts.commitAssociationSource) {
|
|
result[repoRel] = { commits: [], source: "none" };
|
|
continue;
|
|
}
|
|
const associations = await opts.commitAssociationSource.getTaskCommitAssociationsByLineageId(lineageId);
|
|
const reachableShas: string[] = [];
|
|
for (const association of associations) {
|
|
try {
|
|
await runGit(execImpl, `git cat-file -e ${quoteShellArg(`${association.commitSha}^{commit}`)}`, repoRootDir);
|
|
reachableShas.push(association.commitSha);
|
|
} catch {
|
|
// Not reachable in this sub-repo — belongs to another sub-repo or is stale.
|
|
}
|
|
}
|
|
result[repoRel] = { commits: reachableShas, source: reachableShas.length > 0 ? "lineage" : "none" };
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export interface WorkspaceRepoRevertResult {
|
|
repo: string;
|
|
classification: TaskRevertClassification;
|
|
revertCommitSha?: string;
|
|
conflicts?: TaskRevertConflict[];
|
|
alreadyReverted?: boolean;
|
|
}
|
|
|
|
export type WorkspaceTaskRevertResult =
|
|
| { mode: "git"; clean: true; workspace: { repos: WorkspaceRepoRevertResult[] } }
|
|
| { mode: "git"; clean: false; workspace: { repos: WorkspaceRepoRevertResult[] }; conflicts: (TaskRevertConflict & { repo: string })[] }
|
|
| { mode: "git"; unsupported: true; reason: string }
|
|
| { mode: "git"; needsHuman: true; reason: string };
|
|
|
|
export interface RevertWorkspaceTaskOptions {
|
|
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "workspaceWorktrees" | "autoMerge" | "userPaused" | "paused">;
|
|
/** Project root dir; each sub-repo lives at `join(workspaceRootDir, repoRel)` (mirrors `landWorkspaceTask`). */
|
|
workspaceRootDir: string;
|
|
/** Project settings, passed through to `resolveIntegrationBranch` per sub-repo with `integrationBranch`/`baseBranch` stripped (KTD1 — each sub-repo resolves its OWN default). */
|
|
settings: IntegrationBranchSettings;
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
commitAssociationSource?: TaskCommitAssociationSource;
|
|
/** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */
|
|
effectiveAutoMerge?: boolean;
|
|
}
|
|
|
|
interface WorkspaceRepoRevertContext {
|
|
repo: string;
|
|
repoRootDir: string;
|
|
preRevertHead: string;
|
|
commits: string[];
|
|
classification: ClassifyTaskRevertResult;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 — the core safety invariant of
|
|
* this module's workspace path):
|
|
*
|
|
* ALL-OR-NOTHING WHOLE-TASK CLASSIFICATION: this function dry-run classifies
|
|
* EVERY sub-repo FIRST (via the shared `classifyTaskRevert`, which already
|
|
* guarantees a byte-identical per-repo rollback in its own `finally`). The
|
|
* whole task is `clean` iff EVERY acquired sub-repo classifies clean or
|
|
* already-reverted; if ANY sub-repo classifies conflicting, the whole task is
|
|
* `conflicting` and the commit phase never runs for ANY sub-repo.
|
|
*
|
|
* TWO-PHASE COMMIT ORDERING: only after every sub-repo classifies
|
|
* clean/already-reverted does this function re-apply and commit per repo
|
|
* (reusing `applyAndCommitRevert`, the same machinery `performTaskRevert`
|
|
* uses for the single-repo squash path). This classify-all-then-commit-all
|
|
* ordering means a late conflict (the sub-repo's branch moved between
|
|
* classify and apply) can only ever occur DURING the commit phase, never
|
|
* invalidating an already-clean classification from an earlier repo in the
|
|
* same pass.
|
|
*
|
|
* MULTI-REPO ROLLBACK GUARANTEE: if a LATER sub-repo conflicts during the
|
|
* commit phase after an EARLIER sub-repo in this same pass already committed,
|
|
* every already-committed sub-repo is rolled back (`git revert --abort` +
|
|
* `git reset --hard <preRevertHead>`) before returning — so a late conflict
|
|
* can never leave some sub-repos reverted while others are not. Every touched
|
|
* sub-repo worktree is guaranteed byte-identical to its pre-call state on any
|
|
* non-success path.
|
|
*
|
|
* GUARD RAILS (mirrors `performTaskRevert`): only done/archived tasks are
|
|
* revertable; `autoMerge:false` refuses with `needsHuman` instead of forcing
|
|
* a write; this function NEVER mutates the source task's store row/column.
|
|
*/
|
|
export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Promise<WorkspaceTaskRevertResult> {
|
|
const { task, workspaceRootDir } = opts;
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
|
|
if (!REVERTABLE_COLUMNS.has(task.column)) {
|
|
return { mode: "git", needsHuman: true, reason: `task is in column "${task.column}"; only done/archived tasks are revertable` };
|
|
}
|
|
|
|
const effectiveAutoMerge = task.autoMerge ?? opts.effectiveAutoMerge ?? true;
|
|
if (effectiveAutoMerge === false) {
|
|
return { mode: "git", needsHuman: true, reason: "autoMerge is disabled for this task/project; refusing to force-write a revert commit" };
|
|
}
|
|
|
|
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
|
const repoKeys = Object.keys(workspaceWorktrees).sort();
|
|
if (repoKeys.length === 0) {
|
|
return { mode: "git", unsupported: true, reason: "task has no workspaceWorktrees entries; not a workspace task" };
|
|
}
|
|
|
|
const attribution = await resolveWorkspaceTaskRevertCommits(task, {
|
|
workspaceRootDir,
|
|
execAsyncImpl: execImpl,
|
|
commitAssociationSource: opts.commitAssociationSource,
|
|
});
|
|
|
|
// Phase 1: resolve each sub-repo's integration branch, capture its
|
|
// pre-revert HEAD, refuse (without mutating) on a dirty tree, then dry-run
|
|
// classify. classifyTaskRevert already guarantees its OWN byte-identical
|
|
// rollback per repo, so no additional rollback bookkeeping is needed here
|
|
// for the classify phase itself.
|
|
const contexts: WorkspaceRepoRevertContext[] = [];
|
|
for (const repoRel of repoKeys) {
|
|
const repoRootDir = join(workspaceRootDir, repoRel);
|
|
|
|
// Re-resolve THIS sub-repo's integration branch with the shared overrides
|
|
// stripped (KTD1), mirroring `landWorkspaceTask`/self-healing, so each
|
|
// sub-repo resolves its own default rather than inheriting a workspace-wide override.
|
|
let integrationBranch: string;
|
|
try {
|
|
integrationBranch = await resolveIntegrationBranch(repoRootDir, { ...opts.settings, integrationBranch: undefined, baseBranch: undefined });
|
|
} catch (error) {
|
|
throw new TaskRevertError(`failed to resolve integration branch for sub-repo ${repoRel}`, "integration-branch-resolve-failed", error);
|
|
}
|
|
|
|
// FNXC:TaskRevert 2026-07-04-00:00: each sub-repo checkout under
|
|
// `workspaceRootDir` can legitimately sit on any branch (mirrors the
|
|
// single-repo route's `rootDir` branch-mismatch guard) — refuse rather
|
|
// than silently committing a revert onto the wrong branch.
|
|
const currentBranch = (await runGit(execImpl, "git rev-parse --abbrev-ref HEAD", repoRootDir)).stdout.trim();
|
|
if (currentBranch !== integrationBranch) {
|
|
throw new TaskRevertError(
|
|
`sub-repo ${repoRel} checkout is on "${currentBranch}", not its integration branch "${integrationBranch}"; switch to "${integrationBranch}" before reverting`,
|
|
"branch-mismatch",
|
|
);
|
|
}
|
|
|
|
const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", repoRootDir);
|
|
if (statusOut.trim().length > 0) {
|
|
throw new TaskRevertError(
|
|
`working tree for sub-repo ${repoRel} is dirty; refusing to attempt a revert dry-run`,
|
|
"dirty-working-tree",
|
|
);
|
|
}
|
|
|
|
const { stdout: headOut } = await runGit(execImpl, "git rev-parse HEAD", repoRootDir);
|
|
const preRevertHead = headOut.trim();
|
|
|
|
const commits = attribution[repoRel]?.commits ?? [];
|
|
const classification = await classifyTaskRevert({ worktreePath: repoRootDir, commits, execAsyncImpl: execImpl });
|
|
|
|
contexts.push({ repo: repoRel, repoRootDir, preRevertHead, commits, classification });
|
|
}
|
|
|
|
const anyConflicting = contexts.some((ctx) => ctx.classification.classification === "conflicting");
|
|
if (anyConflicting) {
|
|
const repos: WorkspaceRepoRevertResult[] = contexts.map((ctx) => ({
|
|
repo: ctx.repo,
|
|
classification: ctx.classification.classification,
|
|
conflicts: ctx.classification.conflicts,
|
|
alreadyReverted: ctx.classification.alreadyReverted,
|
|
}));
|
|
const conflicts = contexts.flatMap((ctx) =>
|
|
(ctx.classification.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo })),
|
|
);
|
|
return { mode: "git", clean: false, workspace: { repos }, conflicts };
|
|
}
|
|
|
|
// Phase 2: every sub-repo classified clean/already-reverted — apply+commit
|
|
// per repo. Track committed repos so a LATE conflict (branch moved between
|
|
// classify and apply) can roll back every already-committed sub-repo too.
|
|
const repos: WorkspaceRepoRevertResult[] = [];
|
|
const committedRepos: { repo: string; repoRootDir: string; preRevertHead: string }[] = [];
|
|
|
|
try {
|
|
for (const ctx of contexts) {
|
|
if (ctx.classification.classification === "already-reverted" || ctx.commits.length === 0) {
|
|
repos.push({ repo: ctx.repo, classification: "already-reverted", alreadyReverted: true });
|
|
continue;
|
|
}
|
|
|
|
const applied = await applyAndCommitRevert({
|
|
worktreePath: ctx.repoRootDir,
|
|
commits: ctx.commits,
|
|
taskId: task.id,
|
|
execAsyncImpl: execImpl,
|
|
});
|
|
|
|
if (applied.applied) {
|
|
repos.push({ repo: ctx.repo, classification: "clean", revertCommitSha: applied.revertCommitSha });
|
|
committedRepos.push({ repo: ctx.repo, repoRootDir: ctx.repoRootDir, preRevertHead: ctx.preRevertHead });
|
|
continue;
|
|
}
|
|
|
|
if ("alreadyReverted" in applied) {
|
|
repos.push({ repo: ctx.repo, classification: "already-reverted", alreadyReverted: true });
|
|
continue;
|
|
}
|
|
|
|
// Late conflict — applyAndCommitRevert already rolled back THIS repo.
|
|
// Roll back every PREVIOUSLY committed sub-repo in this pass so the
|
|
// whole-task revert stays all-or-nothing.
|
|
for (const committed of committedRepos) {
|
|
await runGit(execImpl, "git revert --abort", committed.repoRootDir).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(committed.preRevertHead)}`, committed.repoRootDir).catch(() => undefined);
|
|
}
|
|
const conflictRepos: WorkspaceRepoRevertResult[] = [
|
|
...repos,
|
|
{ repo: ctx.repo, classification: "conflicting", conflicts: applied.conflicts },
|
|
];
|
|
const conflicts = (applied.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo }));
|
|
return { mode: "git", clean: false, workspace: { repos: conflictRepos }, conflicts };
|
|
}
|
|
} catch (error) {
|
|
// Unexpected failure mid-pass — roll back every already-committed sub-repo.
|
|
for (const committed of committedRepos) {
|
|
await runGit(execImpl, "git revert --abort", committed.repoRootDir).catch(() => undefined);
|
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(committed.preRevertHead)}`, committed.repoRootDir).catch(() => undefined);
|
|
}
|
|
throw error instanceof TaskRevertError ? error : new TaskRevertError("failed to apply workspace revert", "workspace-revert-apply-failed", error);
|
|
}
|
|
|
|
return { mode: "git", clean: true, workspace: { repos } };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FN-7577: PR-based revert for WORKSPACE (multi-repo) tasks under autoMerge:false.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface WorkspaceRepoRevertPrBranch {
|
|
repo: string;
|
|
/** Same branch NAME across every sub-repo (`fusion/revert-<task-id-lowercase>`). */
|
|
revertBranch: string;
|
|
/** This sub-repo's resolved integration branch — the PR base. NEVER written to. */
|
|
integrationBranch: string;
|
|
revertCommitShas: string[];
|
|
}
|
|
|
|
export type PrepareWorkspaceRevertPrBranchesResult =
|
|
| { eligible: true; repos: WorkspaceRepoRevertPrBranch[] }
|
|
| {
|
|
eligible: false;
|
|
classification: "conflicting";
|
|
conflicts: (TaskRevertConflict & { repo: string })[];
|
|
repos: WorkspaceRepoRevertResult[];
|
|
}
|
|
| { eligible: false; unsupported: true; reason: string };
|
|
|
|
export interface PrepareWorkspaceRevertPrBranchesOptions {
|
|
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "workspaceWorktrees">;
|
|
/** Project root dir; each sub-repo lives at `join(workspaceRootDir, repoRel)` (mirrors `revertWorkspaceTask`). */
|
|
workspaceRootDir: string;
|
|
/** Project settings, passed through to `resolveIntegrationBranch` per sub-repo with `integrationBranch`/`baseBranch` stripped (KTD1). */
|
|
settings: IntegrationBranchSettings;
|
|
/** e.g. `fusion/revert-<task-id-lowercase>` — the SAME branch name prepared in every sub-repo. */
|
|
revertBranch: string;
|
|
execAsyncImpl?: ExecAsyncImpl;
|
|
commitAssociationSource?: TaskCommitAssociationSource;
|
|
}
|
|
|
|
interface WorkspaceRepoRevertPrContext {
|
|
repo: string;
|
|
repoRootDir: string;
|
|
integrationBranch: string;
|
|
commits: string[];
|
|
classification: ClassifyTaskRevertResult;
|
|
}
|
|
|
|
type PrepareOneWorkspaceRepoBranchOutcome =
|
|
| { kind: "applied"; revertCommitSha: string }
|
|
| { kind: "already-reverted" }
|
|
| { kind: "conflicts"; conflicts: TaskRevertConflict[] };
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — per-sub-repo branch prep
|
|
* primitive, mirrors FN-7554's single-repo `prepareRevertPrBranch` body):
|
|
* Prepares ONE sub-repo's dedicated `revertBranch` off `integrationBranch`
|
|
* HEAD (`-B`, idempotent re-run), applies+commits via the shared
|
|
* `applyAndCommitRevert` (REUSE, not reimplementation — same helper
|
|
* `performTaskRevert`/`revertWorkspaceTask`/`prepareRevertPrBranch` all use),
|
|
* and ALWAYS restores the sub-repo checkout back to `integrationBranch`
|
|
* before returning — `integrationBranch`'s ref itself is never advanced,
|
|
* reset, or committed to. A redundant/failed local `revertBranch` (nothing
|
|
* to commit, or a late apply-time conflict) is deleted so this sub-repo is
|
|
* left byte-identical to its pre-call state whenever no PR branch results.
|
|
*/
|
|
async function prepareOneWorkspaceRepoRevertBranch(opts: {
|
|
execImpl: ExecAsyncImpl;
|
|
repoRootDir: string;
|
|
integrationBranch: string;
|
|
revertBranch: string;
|
|
commits: string[];
|
|
taskId: string;
|
|
}): Promise<PrepareOneWorkspaceRepoBranchOutcome> {
|
|
const { execImpl, repoRootDir, integrationBranch, revertBranch, commits, taskId } = opts;
|
|
let branchCreated = false;
|
|
try {
|
|
// FNXC:TaskRevert 2026-07-05-00:00: `-B` (create-or-reset) makes
|
|
// re-running this idempotent when a stale local `revertBranch` already
|
|
// exists from a prior failed/aborted attempt — reset off
|
|
// `integrationBranch` HEAD rather than accumulating on top of whatever it
|
|
// previously pointed at. `integrationBranch` is only ever READ here.
|
|
await runGit(execImpl, `git checkout -B ${quoteShellArg(revertBranch)} ${quoteShellArg(integrationBranch)}`, repoRootDir);
|
|
branchCreated = true;
|
|
|
|
const applied = await applyAndCommitRevert({ worktreePath: repoRootDir, commits, taskId, execAsyncImpl: execImpl });
|
|
|
|
if ("alreadyReverted" in applied) {
|
|
// Defensive: the branch moved between classify and apply — nothing to
|
|
// commit on the fresh revertBranch. Delete the now-redundant branch so
|
|
// this sub-repo contributes no branch to the caller.
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
|
|
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined);
|
|
return { kind: "already-reverted" };
|
|
}
|
|
if ("conflicts" in applied) {
|
|
// Late conflict — applyAndCommitRevert already rolled repoRootDir back to
|
|
// the tip of revertBranch (== integrationBranch HEAD). Delete the
|
|
// now-redundant branch; the caller handles multi-repo rollback.
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
|
|
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined);
|
|
return { kind: "conflicts", conflicts: applied.conflicts };
|
|
}
|
|
|
|
// applied.applied === true — leave `revertBranch` in place (it IS the PR
|
|
// branch) but restore the sub-repo checkout back to `integrationBranch`,
|
|
// never leaving it mid-revert on `revertBranch`.
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
|
|
return { kind: "applied", revertCommitSha: applied.revertCommitSha };
|
|
} catch (error) {
|
|
// On any thrown failure after branch creation, never leave a dangling
|
|
// partial revert branch behind — best-effort restore + delete.
|
|
if (branchCreated) {
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
|
|
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined);
|
|
}
|
|
throw error instanceof TaskRevertError
|
|
? error
|
|
: new TaskRevertError("failed to prepare revert branch for sub-repo", "revert-pr-branch-prepare-failed", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — workspace PR-revert branch prep,
|
|
* the all-or-nothing gate this task adds on top of FN-7554/FN-7547):
|
|
*
|
|
* NEVER-WRITE-TO-INTEGRATION-BRANCH INVARIANT: mirrors `prepareRevertPrBranch`
|
|
* (single-repo) extended to N sub-repos — no sub-repo's integration branch
|
|
* ref is EVER advanced, reset, or committed to. Every revert commit lives
|
|
* ONLY on that sub-repo's `<revertBranch>` (same branch NAME across every
|
|
* sub-repo, distinct branch OBJECT per sub-repo git history).
|
|
*
|
|
* CLASSIFY-ALL-THEN-PREP-ALL (Phase 1 / Phase 2), mirroring
|
|
* `revertWorkspaceTask`'s whole-task all-or-nothing contract: Phase 1
|
|
* dry-run classifies EVERY sub-repo first (reusing the shared
|
|
* `classifyTaskRevert`, itself always rolling each tree back
|
|
* byte-identical). If ANY sub-repo classifies `conflicting`, this function
|
|
* returns immediately with NO branch created anywhere — Phase 2 (branch
|
|
* prep) never runs for ANY sub-repo. Only when every sub-repo classifies
|
|
* clean/already-reverted does Phase 2 run, preparing one dedicated
|
|
* `<revertBranch>` per sub-repo that actually has commits to revert
|
|
* (`prepareOneWorkspaceRepoRevertBranch`, reusing `applyAndCommitRevert` — no
|
|
* commit-message/trailer duplication). A LATE conflict during Phase 2 (a
|
|
* sub-repo's branch moved between classify and apply) rolls back every
|
|
* PREVIOUSLY prepped sub-repo's branch in this pass (checkout back +
|
|
* `git branch -D`) before returning conflicting, so the whole preparation
|
|
* stays all-or-nothing even when the failure surfaces mid-pass rather than
|
|
* during classification.
|
|
*
|
|
* THIS FUNCTION DOES NOT GATE ON `autoMerge` — the caller (the API route)
|
|
* decides when to invoke this primitive; it stays a pure branch-prep
|
|
* building block usable independent of that policy decision.
|
|
*/
|
|
export async function prepareWorkspaceRevertPrBranches(
|
|
opts: PrepareWorkspaceRevertPrBranchesOptions,
|
|
): Promise<PrepareWorkspaceRevertPrBranchesResult> {
|
|
const { task, workspaceRootDir, revertBranch } = opts;
|
|
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
|
|
|
|
if (!isWorkspaceTask(task) || Object.keys(task.workspaceWorktrees ?? {}).length === 0) {
|
|
return { eligible: false, unsupported: true, reason: "not-a-workspace-task" };
|
|
}
|
|
|
|
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
|
const repoKeys = Object.keys(workspaceWorktrees).sort();
|
|
|
|
const attribution = await resolveWorkspaceTaskRevertCommits(task, {
|
|
workspaceRootDir,
|
|
execAsyncImpl: execImpl,
|
|
commitAssociationSource: opts.commitAssociationSource,
|
|
});
|
|
|
|
// Phase 1: resolve each sub-repo's integration branch, refuse (without
|
|
// mutating) on branch-mismatch/dirty-tree, then dry-run classify EVERY
|
|
// sub-repo — mirrors `revertWorkspaceTask`'s Phase 1 verbatim.
|
|
const contexts: WorkspaceRepoRevertPrContext[] = [];
|
|
for (const repoRel of repoKeys) {
|
|
const repoRootDir = join(workspaceRootDir, repoRel);
|
|
|
|
let integrationBranch: string;
|
|
try {
|
|
integrationBranch = await resolveIntegrationBranch(repoRootDir, { ...opts.settings, integrationBranch: undefined, baseBranch: undefined });
|
|
} catch (error) {
|
|
throw new TaskRevertError(`failed to resolve integration branch for sub-repo ${repoRel}`, "integration-branch-resolve-failed", error);
|
|
}
|
|
|
|
const currentBranch = (await runGit(execImpl, "git rev-parse --abbrev-ref HEAD", repoRootDir)).stdout.trim();
|
|
if (currentBranch !== integrationBranch) {
|
|
throw new TaskRevertError(
|
|
`sub-repo ${repoRel} checkout is on "${currentBranch}", not its integration branch "${integrationBranch}"; switch to "${integrationBranch}" before preparing a revert PR branch`,
|
|
"branch-mismatch",
|
|
);
|
|
}
|
|
|
|
const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", repoRootDir);
|
|
if (statusOut.trim().length > 0) {
|
|
throw new TaskRevertError(
|
|
`working tree for sub-repo ${repoRel} is dirty; refusing to prepare a revert PR branch`,
|
|
"dirty-working-tree",
|
|
);
|
|
}
|
|
|
|
const commits = attribution[repoRel]?.commits ?? [];
|
|
const classification = await classifyTaskRevert({ worktreePath: repoRootDir, commits, execAsyncImpl: execImpl });
|
|
|
|
contexts.push({ repo: repoRel, repoRootDir, integrationBranch, commits, classification });
|
|
}
|
|
|
|
const anyConflicting = contexts.some((ctx) => ctx.classification.classification === "conflicting");
|
|
if (anyConflicting) {
|
|
const repos: WorkspaceRepoRevertResult[] = contexts.map((ctx) => ({
|
|
repo: ctx.repo,
|
|
classification: ctx.classification.classification,
|
|
conflicts: ctx.classification.conflicts,
|
|
alreadyReverted: ctx.classification.alreadyReverted,
|
|
}));
|
|
const conflicts = contexts.flatMap((ctx) =>
|
|
(ctx.classification.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo })),
|
|
);
|
|
return { eligible: false, classification: "conflicting", conflicts, repos };
|
|
}
|
|
|
|
// Phase 2: every sub-repo classified clean/already-reverted — prepare a
|
|
// dedicated revert branch per sub-repo that actually has commits to revert.
|
|
const preppedRepos: WorkspaceRepoRevertPrBranch[] = [];
|
|
const preppedForRollback: { repo: string; repoRootDir: string; integrationBranch: string }[] = [];
|
|
|
|
try {
|
|
for (const ctx of contexts) {
|
|
if (ctx.classification.classification === "already-reverted" || ctx.commits.length === 0) {
|
|
// Nothing to revert in this sub-repo — contributes no branch.
|
|
continue;
|
|
}
|
|
|
|
const outcome = await prepareOneWorkspaceRepoRevertBranch({
|
|
execImpl,
|
|
repoRootDir: ctx.repoRootDir,
|
|
integrationBranch: ctx.integrationBranch,
|
|
revertBranch,
|
|
commits: ctx.commits,
|
|
taskId: task.id,
|
|
});
|
|
|
|
if (outcome.kind === "already-reverted") {
|
|
continue;
|
|
}
|
|
|
|
if (outcome.kind === "conflicts") {
|
|
// Late conflict — roll back every PREVIOUSLY prepped sub-repo's branch
|
|
// in this pass so the whole preparation stays all-or-nothing.
|
|
for (const prepped of preppedForRollback) {
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(prepped.integrationBranch)}`, prepped.repoRootDir).catch(() => undefined);
|
|
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, prepped.repoRootDir).catch(() => undefined);
|
|
}
|
|
const conflicts = outcome.conflicts.map((conflict) => ({ ...conflict, repo: ctx.repo }));
|
|
const repos: WorkspaceRepoRevertResult[] = contexts.map((c) => ({
|
|
repo: c.repo,
|
|
classification: c.repo === ctx.repo ? "conflicting" : c.classification.classification,
|
|
conflicts: c.repo === ctx.repo ? outcome.conflicts : c.classification.conflicts,
|
|
alreadyReverted: c.classification.alreadyReverted,
|
|
}));
|
|
return { eligible: false, classification: "conflicting", conflicts, repos };
|
|
}
|
|
|
|
// outcome.kind === "applied"
|
|
preppedRepos.push({
|
|
repo: ctx.repo,
|
|
revertBranch,
|
|
integrationBranch: ctx.integrationBranch,
|
|
revertCommitShas: [outcome.revertCommitSha],
|
|
});
|
|
preppedForRollback.push({ repo: ctx.repo, repoRootDir: ctx.repoRootDir, integrationBranch: ctx.integrationBranch });
|
|
}
|
|
} catch (error) {
|
|
// Unexpected thrown failure mid-pass — best-effort restore + delete every
|
|
// already-prepped sub-repo branch so a failed prep never leaves dangling
|
|
// half-built revert branches.
|
|
for (const prepped of preppedForRollback) {
|
|
await runGit(execImpl, `git checkout ${quoteShellArg(prepped.integrationBranch)}`, prepped.repoRootDir).catch(() => undefined);
|
|
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, prepped.repoRootDir).catch(() => undefined);
|
|
}
|
|
throw error instanceof TaskRevertError
|
|
? error
|
|
: new TaskRevertError("failed to prepare workspace revert PR branches", "workspace-revert-pr-branch-prepare-failed", error);
|
|
}
|
|
|
|
return { eligible: true, repos: preppedRepos };
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// FN-7524: AI-undo fallback
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (AI-undo marker contract):
|
|
* `REVERT_OF_METADATA_KEY` is the idempotency key stamped onto an AI-undo
|
|
* board task's `source.sourceMetadata`. The route's dedup guard
|
|
* (`TaskStore.findOpenRevertTaskForSource`, core) scans OPEN (non
|
|
* done/archived) tasks for `sourceMetadata.revertOf === sourceTaskId` before
|
|
* creating a new one — a second `mode:"ai"`/conflict-fallback call for the
|
|
* same source task while an undo task is still open MUST return the existing
|
|
* task's id (`alreadyOpen: true`) instead of creating a duplicate. A prior
|
|
* undo task that has itself reached `done`/`archived` does NOT suppress a
|
|
* fresh one — the work may need undoing again (e.g. redone, then relanded).
|
|
* NEVER repurpose this key for another meaning.
|
|
*/
|
|
export const REVERT_OF_METADATA_KEY = "revertOf" as const;
|
|
|
|
export type AiUndoTaskResult = { mode: "ai"; createdTaskId: string; alreadyOpen?: boolean };
|
|
|
|
function formatLandedFiles(landedFiles: string[] | undefined): string {
|
|
if (!landedFiles || landedFiles.length === 0) {
|
|
return "(no landed-files list recorded on this task; inspect its merge commit(s) directly)";
|
|
}
|
|
return landedFiles.map((file) => `- ${file}`).join("\n");
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (AI-undo mission contract):
|
|
* Builds the triage-ready description for the AI-undo board task. References
|
|
* the source task's id, its mission (`task.prompt` when present, else
|
|
* `task.description` — `prompt` carries the fuller generated spec when
|
|
* available), its landed files (`mergeDetails.landedFiles`) plus a pointer to
|
|
* `GET /api/tasks/<id>/diff` for the full landed diff (reused, not
|
|
* recomputed), an explicit instruction to undo the BEHAVIOR/FILES the source
|
|
* task introduced while PRESERVING unrelated changes later tasks made to the
|
|
* same files, and the `revert(FN-xxxx): …` commit convention with a
|
|
* `Fusion-Task-Id: FN-xxxx` trailer referencing the ORIGINAL task (consistent
|
|
* with the git-path commit convention above `performTaskRevert`).
|
|
*/
|
|
export function buildAiUndoTaskDescription(params: {
|
|
task: Pick<Task, "id" | "title" | "description" | "prompt" | "mergeDetails">;
|
|
}): string {
|
|
const { task } = params;
|
|
const mission = task.prompt?.trim() ? task.prompt : task.description;
|
|
const landedFiles = task.mergeDetails?.landedFiles;
|
|
|
|
return [
|
|
`Undo the work landed by task ${task.id}${task.title ? ` — "${task.title}"` : ""}.`,
|
|
"",
|
|
"## Why this task exists",
|
|
`A direct \`git revert\` of ${task.id} could not be applied automatically (later commits conflict with it, the task's revert is unsupported, or AI-undo mode was explicitly requested). This task undoes the BEHAVIOR/FILES ${task.id} introduced WHILE PRESERVING unrelated changes made by later tasks that also touched the same files — do not blindly restore the pre-${task.id} version of any shared file.`,
|
|
"",
|
|
`## Original mission (${task.id})`,
|
|
mission,
|
|
"",
|
|
`## Files landed by ${task.id}`,
|
|
formatLandedFiles(landedFiles),
|
|
`See \`GET /api/tasks/${task.id}/diff\` for the full landed diff.`,
|
|
"",
|
|
"## What to do",
|
|
`1. Read ${task.id}'s original mission above and its landed diff.`,
|
|
`2. For each file ${task.id} touched, remove or reverse ONLY the behavior/changes it introduced. If a later task also modified the same file, preserve that later task's unrelated changes.`,
|
|
`3. Commit the undo work using the \`revert(${task.id}): <short summary>\` commit-message convention with a \`Fusion-Task-Id: ${task.id}\` trailer, so the commit stays attributable back to ${task.id} (mirrors the direct git-revert commit convention).`,
|
|
"4. Verify the original behavior is gone (tests/build) and that later, unrelated changes to the same files still work as intended.",
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (dependency-free creation rule):
|
|
* The AI-undo task is created via the store's normal `createTask` path
|
|
* (lands in `triage`, gets its own generated PROMPT.md) with `dependencies: []`
|
|
* — it must NEVER depend on the source task. The source task is already
|
|
* done/archived; a dependency on it would be a permanently-satisfied no-op
|
|
* that misrepresents the relationship in dependency UIs.
|
|
*/
|
|
export interface CreateAiUndoTaskDeps {
|
|
createTask(input: TaskCreateInput): Promise<Task>;
|
|
/** Idempotency lookup — see `REVERT_OF_METADATA_KEY`. Implemented by `TaskStore.findOpenRevertTaskForSource` (core). */
|
|
findOpenRevertTaskForSource(sourceTaskId: string): Promise<Task | null>;
|
|
sourceTask: Pick<Task, "id" | "title" | "description" | "prompt" | "mergeDetails" | "priority">;
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7556):
|
|
* Workflow id to select for the created AI-undo task, forwarded verbatim
|
|
* into `createTask({ workflowId })`. The CALLER (the route) resolves and
|
|
* validates this from the `aiUndoTaskWorkflowId` project setting (default
|
|
* `builtin:review-heavy`) — this helper stays pure and never reads
|
|
* settings/the store itself. A blank/undefined value means the created
|
|
* task INHERITS the project default workflow (`createTask`'s `undefined`
|
|
* semantics) — do NOT pass `null` here, which would mean "no workflow".
|
|
*/
|
|
workflowId?: string;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskRevert 2026-07-04-00:00 (Step 1 entry point):
|
|
* Creates (or, if an open one already exists for this source task, returns
|
|
* the existing) AI-undo board task. This is the fallback the route uses when
|
|
* the git-revert path cannot apply cleanly / is unsupported, or when the
|
|
* caller explicitly requests `mode:"ai"`.
|
|
*/
|
|
export async function createAiUndoTask(deps: CreateAiUndoTaskDeps): Promise<AiUndoTaskResult> {
|
|
const { sourceTask } = deps;
|
|
|
|
// Idempotency FIRST — never create a duplicate while one is still open.
|
|
const existing = await deps.findOpenRevertTaskForSource(sourceTask.id);
|
|
if (existing) {
|
|
return { mode: "ai", createdTaskId: existing.id, alreadyOpen: true };
|
|
}
|
|
|
|
const description = buildAiUndoTaskDescription({ task: sourceTask });
|
|
// FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): forward `workflowId` ONLY when
|
|
// a non-empty string is supplied; otherwise omit the key entirely so
|
|
// `createTask` inherits the project default (never pass `null`/"" through).
|
|
const workflowId = deps.workflowId && deps.workflowId.trim() !== "" ? deps.workflowId : undefined;
|
|
const created = await deps.createTask({
|
|
title: `Undo ${sourceTask.id}: ${sourceTask.title ?? sourceTask.description.slice(0, 80)}`,
|
|
description,
|
|
dependencies: [],
|
|
priority: sourceTask.priority,
|
|
source: {
|
|
sourceType: "recovery",
|
|
sourceMetadata: { [REVERT_OF_METADATA_KEY]: sourceTask.id },
|
|
},
|
|
...(workflowId !== undefined ? { workflowId } : {}),
|
|
});
|
|
|
|
return { mode: "ai", createdTaskId: created.id };
|
|
}
|