diff --git a/.changeset/fn-7547-workspace-task-revert.md b/.changeset/fn-7547-workspace-task-revert.md new file mode 100644 index 0000000000..1404ce5d4c --- /dev/null +++ b/.changeset/fn-7547-workspace-task-revert.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Support reverting multi-repo workspace tasks via git, all-or-nothing across sub-repos. +category: feature +dev: Extends `packages/engine/src/task-revert.ts` with `resolveWorkspaceTaskRevertCommits`/`revertWorkspaceTask` and wires `POST /api/tasks/:id/revert` to dispatch workspace tasks (`isWorkspaceTask`) to the new path; returns `{ mode: "git", clean, workspace: { repos: [...] }, conflicts? }`. Single-repo `performTaskRevert` path is unchanged. diff --git a/docs/task-management.md b/docs/task-management.md index 075b23c936..4aad65d790 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -680,13 +680,15 @@ Recovery/backfill guidance: - The route accepts an optional request body `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`; unknown values reject with 400): - `"git"` — the FN-7523 git-only behavior. The result (including a conflicting/unsupported result) is returned as-is and never creates a follow-up task. - `"ai"` — skip git entirely and always create the AI-undo fallback task (FN-7524). - - `"auto"` — try git first. A clean/alreadyReverted/needsHuman result is returned unchanged. A conflicting or unsupported (e.g. workspace-task) result falls back to creating the AI-undo task. -- Also accepts an optional `{ granularity?: "squash" | "per-sha" }` field (FN-7548) that selects the git-path commit granularity: `"squash"` (default, unchanged) accumulates all attributable commits into one revert commit; `"per-sha"` creates one attributed revert commit per original sha (each with its own `Fusion-Task-Id` trailer and audit line), skipping no-op shas without empty commits. A mid-batch conflict in either mode rolls back the whole batch — no partially-landed per-sha commits. This field only affects the git path and is ignored when `mode` resolves to `"ai"`. + - `"auto"` — try git first. A clean/alreadyReverted/needsHuman result is returned unchanged. A conflicting or unsupported result falls back to creating the AI-undo task. +- Also accepts an optional `{ granularity?: "squash" | "per-sha" }` field (FN-7548) that selects the git-path commit granularity: `"squash"` (default, unchanged) accumulates all attributable commits into one revert commit; `"per-sha"` creates one attributed revert commit per original sha (each with its own `Fusion-Task-Id` trailer and audit line), skipping no-op shas without empty commits. A mid-batch conflict in either mode rolls back the whole batch — no partially-landed per-sha commits. This field only affects the single-repo git path and is ignored when `mode` resolves to `"ai"` or the task is a workspace task. - Git-path response contract (additive only): `{ mode: "git", clean, revertCommitSha?, revertCommitShas?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch; `revertCommitShas` reports every commit created (all of them for `per-sha`, the single one for `squash`) alongside the existing `revertCommitSha`. - AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one. -- Workspace (multi-repo) tasks return `unsupported` from the git path (routing `auto` to the AI-undo fallback); `autoMerge:false` projects return `needsHuman` and never trigger the AI-undo fallback (a human/future UI decides). +- **Workspace (multi-repo) tasks (FN-7547):** tasks with `workspaceWorktrees` populated (`isWorkspaceTask`) are revertable too — the route dispatches to a dedicated workspace path that reasons about every sub-repo's integration branch as ONE all-or-nothing unit. It resolves each sub-repo's attributable commit(s), dry-run classifies every sub-repo first, and only commits a `revert(FN-xxxx): ...` commit on EACH sub-repo when every 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 to its pre-call state. Response contract for workspace tasks: `{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?, alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }`. A conflicting workspace result still falls back to the AI-undo task under `"auto"` mode, same as a single-repo conflicting result. +- `autoMerge:false` projects return `needsHuman` and never trigger the AI-undo fallback (a human/future UI decides). - No dashboard UI affordance ships with this yet (see the Done/Archived card action follow-up task). + ## GitHub Issue Import and PR Creation GitLab enablement, instance/API URL, and access-token configuration are available in Settings for GitLab.com and self-managed GitLab (`gitlabEnabled`, `gitlabInstanceUrl`, optional `gitlabApiBaseUrl`, `gitlabAuthToken`, `gitlabAuthTokenType`). Fusion accepts personal, project, and group access tokens for GitLab HTTP API import tasks; read-only project issue, group issue, and merge request imports require `read_api` or `api`, while later write actions such as comments and auto-close require `api`. diff --git a/packages/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index 134e87f239..9684e863db 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -35,12 +35,14 @@ function makeGitRepoOnMain(): string { } const performTaskRevertMock = vi.fn(); +const revertWorkspaceTaskMock = vi.fn(); vi.mock("@fusion/engine", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, performTaskRevert: (...args: unknown[]) => performTaskRevertMock(...args), + revertWorkspaceTask: (...args: unknown[]) => revertWorkspaceTaskMock(...args), }; }); @@ -64,6 +66,22 @@ function makeTask(overrides: Partial): Task { } as Task; } +// FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 — workspace dispatch coverage): +// Workspace tasks (`workspaceWorktrees` populated) must route to +// `revertWorkspaceTask` instead of `performTaskRevert` — real per-repo git +// behavior (attribution/classification/all-or-nothing rollback) is proven in +// packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts; this +// suite only asserts the route dispatch and per-repo response shape. +function makeWorkspaceTask(overrides: Partial): Task { + return makeTask({ + workspaceWorktrees: { + "repo-a": { worktreePath: "/tmp/repo-a", branch: "fusion/FN-100", landedSha: "aaa111" }, + "repo-b": { worktreePath: "/tmp/repo-b", branch: "fusion/FN-100", landedSha: "bbb222" }, + }, + ...overrides, + }); +} + function createMockStore( task: Task, opts?: { openUndoTask?: Task | null; createdUndoTask?: Task }, @@ -210,6 +228,90 @@ describe("POST /tasks/:id/revert", () => { expect((res.body as { details?: { code?: string } }).details?.code ?? (res.body as { error?: string }).error).toBeTruthy(); expect(performTaskRevertMock).not.toHaveBeenCalled(); }); + + it("dispatches a done workspace task to revertWorkspaceTask and returns the per-repo breakdown (clean)", async () => { + const task = makeWorkspaceTask({ column: "done" }); + const store = createMockStore(task); + revertWorkspaceTaskMock.mockResolvedValue({ + mode: "git", + clean: true, + workspace: { + repos: [ + { repo: "repo-a", classification: "clean", revertCommitSha: "rev-a" }, + { repo: "repo-b", classification: "clean", revertCommitSha: "rev-b" }, + ], + }, + }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + mode: "git", + clean: true, + workspace: { repos: [{ repo: "repo-a" }, { repo: "repo-b" }] }, + }); + expect(revertWorkspaceTaskMock).toHaveBeenCalledTimes(1); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); + + it("mode:'git' dispatches a workspace task conflict to the per-repo conflict shape without creating an AI-undo task or calling performTaskRevert", async () => { + const task = makeWorkspaceTask({ column: "archived" }); + const store = createMockStore(task); + revertWorkspaceTaskMock.mockResolvedValue({ + mode: "git", + clean: false, + workspace: { + repos: [ + { repo: "repo-a", classification: "clean", revertCommitSha: "rev-a" }, + { repo: "repo-b", classification: "conflicting", conflicts: [{ file: "b.ts", status: "UU" }] }, + ], + }, + conflicts: [{ repo: "repo-b", file: "b.ts", status: "UU" }], + }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "git" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + mode: "git", + clean: false, + conflicts: [{ repo: "repo-b", file: "b.ts" }], + }); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); + + // FN-7547 + FN-7524: default mode is "auto", which falls back to the AI-undo + // task on a conflicting WORKSPACE result too, same as the single-repo contract. + it("auto (default) mode falls back to the AI-undo task on a conflicting workspace result", async () => { + const task = makeWorkspaceTask({ id: "FN-950", column: "archived" }); + const store = createMockStore(task); + revertWorkspaceTaskMock.mockResolvedValue({ + mode: "git", + clean: false, + workspace: { + repos: [ + { repo: "repo-a", classification: "clean", revertCommitSha: "rev-a" }, + { repo: "repo-b", classification: "conflicting", conflicts: [{ file: "b.ts", status: "UU" }] }, + ], + }, + conflicts: [{ repo: "repo-b", file: "b.ts", status: "UU" }], + }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "ai" }); + expect((res.body as { createdTaskId?: string }).createdTaskId).toBeTruthy(); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); + + it("rejects a non-done/archived workspace task with a 4xx guard before invoking the workspace service", async () => { + const task = makeWorkspaceTask({ column: "in-progress" }); + const store = createMockStore(task); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + expect(revertWorkspaceTaskMock).not.toHaveBeenCalled(); + }); }); // FN-7524 Symptom Verification: `{ mode }` request handling + the AI-undo fallback. diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 339ba40530..da1ade20c0 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -53,13 +53,14 @@ import { planTaskWorktreePath, promoteHeldTask, performTaskRevert, + revertWorkspaceTask, TaskRevertError, createAiUndoTask, type AiUndoTaskResult, } from "@fusion/engine"; import { buildBoardWorkflowsPayload } from "./board-workflows.js"; import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js"; -import type { RunAuditEventInput } from "@fusion/core"; +import { isWorkspaceTask, type RunAuditEventInput } from "@fusion/core"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import type { ApiRoutesContext } from "./types.js"; import { deriveAutoTaskBranch, derivePerTaskBranch, getBranchSelectionMode, resolveBranchSelection } from "./branch-selection.js"; @@ -1670,10 +1671,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork }); /* - FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract; FN-7548 granularity contract): + FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract; FN-7547 workspace dispatch; FN-7548 granularity contract): POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523), with an - AI-undo fallback (FN-7524, foundation for FN-7501) and per-sha revert-commit granularity - (FN-7548). Guard rails (enforced here AND in the engine service): + AI-undo fallback (FN-7524, foundation for FN-7501), workspace (multi-repo) task support + (FN-7547), and per-sha revert-commit granularity (FN-7548). Guard rails (enforced here AND in + the engine service): - only done/archived tasks are revertable (400/409 otherwise); - autoMerge-off is a needsHuman result, not a forced write, and NEVER triggers the AI fallback (leave that for a human / sibling FN-7525 to decide); @@ -1685,15 +1687,18 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork returned as-is and the AI-undo path is NEVER invoked. - `"ai"` — skip git entirely; always take the AI-undo fallback. - `"auto"` — attempt git first. A clean/alreadyReverted/needsHuman git result is returned - unchanged (NO AI task created). A conflicting or unsupported (e.g. workspace-task) git result - falls through to the AI-undo fallback. + unchanged (NO AI task created). A conflicting or unsupported git result falls through to + the AI-undo fallback. - `granularity?: "squash" | "per-sha"` (FN-7548, default `"squash"`) — commit granularity for the - git-path revert only; forwarded verbatim to `performTaskRevert`. `"squash"` preserves the - unchanged FN-7523 single-commit behavior; `"per-sha"` creates one attributed revert commit per - original sha (see `performTaskRevert`'s per-sha apply path). Ignored when `mode` resolves to `"ai"`. + single-repo git-path revert only; forwarded verbatim to `performTaskRevert`. `"squash"` + preserves the unchanged FN-7523 single-commit behavior; `"per-sha"` creates one attributed + revert commit per original sha (see `performTaskRevert`'s per-sha apply path). Ignored when + `mode` resolves to `"ai"` or the task is a workspace task. Response contract is ADDITIVE over FN-7523: `{ mode: "git", clean, revertCommitSha?, revertCommitShas?, - conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }` OR + conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }` OR, for workspace tasks (FN-7547), + `{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?, + alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }` OR `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via `createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency guard — a second call while an undo task is still open returns the SAME `createdTaskId` with @@ -1723,8 +1728,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork combined revert commit) or `"per-sha"` (one attributed revert commit per original sha, see `performTaskRevert`'s per-sha apply path). An absent/ empty value defaults to `"squash"`; any other value is a 400 naming the - allowed values. Only relevant to the git path — ignored when `mode` - resolves to `"ai"`. + allowed values. Only relevant to the single-repo git path — ignored when + `mode` resolves to `"ai"` or the task is a workspace task. */ const requestedGranularity = (req.body as { granularity?: unknown } | undefined)?.granularity; let granularity: "squash" | "per-sha" = "squash"; @@ -1749,6 +1754,47 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork const rootDir = scopedStore.getRootDir(); const settings = await scopedStore.getSettingsFast(); + + /* + FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 — workspace dispatch): + Workspace tasks (`isWorkspaceTask(task)`) land commits across MULTIPLE + sub-repo integration branches under `rootDir` (each sub-repo lives at + `join(rootDir, repoRel)`, mirroring `landWorkspaceTask`) — there is no + single `baseBranch`/`rootDir`-is-a-git-repo assumption to check here. + Route straight to `revertWorkspaceTask`, which resolves + branch-checks + + dry-run classifies + commits EACH sub-repo itself, enforcing the + whole-task all-or-nothing contract. The single-repo path below is + UNCHANGED. `granularity` does not apply to the workspace path. + */ + if (isWorkspaceTask(task)) { + const workspaceResult = await revertWorkspaceTask({ + task, + workspaceRootDir: rootDir, + settings, + commitAssociationSource: { + getTaskCommitAssociationsByLineageId: (lineageId: string) => + scopedStore.getTaskCommitAssociationsByLineageId(lineageId), + }, + effectiveAutoMerge: settings.autoMerge, + }); + + if (mode === "git") { + res.json(workspaceResult); + return; + } + + // mode === "auto": fall back to the AI-undo task on a conflicting workspace + // result, same as the single-repo conflicting-result contract below. + const workspaceShouldFallBackToAi = workspaceResult.mode === "git" && "clean" in workspaceResult && workspaceResult.clean === false; + if (workspaceShouldFallBackToAi) { + res.json(await createAiUndoResult()); + return; + } + + res.json(workspaceResult); + return; + } + const baseBranch = task.mergeDetails?.mergeTargetBranch || await resolveIntegrationBranch(rootDir, settings); /* @@ -1791,8 +1837,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } // mode === "auto": fall back to the AI-undo task ONLY on conflict or an - // unsupported (e.g. workspace) git result. Clean/alreadyReverted/needsHuman - // results are returned as-is — needsHuman (autoMerge-off) NEVER triggers AI. + // unsupported git result. Clean/alreadyReverted/needsHuman results are + // returned as-is — needsHuman (autoMerge-off) NEVER triggers AI. const shouldFallBackToAi = (result.mode === "git" && "clean" in result && result.clean === false) || (result.mode === "git" && "unsupported" in result && result.unsupported === true); @@ -1808,7 +1854,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork throw err; } if (err instanceof TaskRevertError) { - const status = err.code === "dirty-working-tree" ? 409 : 500; + const status = err.code === "dirty-working-tree" || err.code === "branch-mismatch" ? 409 : 500; throw new ApiError(status, err.message, { code: err.code }); } rethrowAsApiError(err); diff --git a/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts b/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts new file mode 100644 index 0000000000..b523534698 --- /dev/null +++ b/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts @@ -0,0 +1,272 @@ +import { execSync, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resolveWorkspaceTaskRevertCommits, + revertWorkspaceTask, +} from "../task-revert.js"; +import type { Task } from "@fusion/core"; + +const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +function makeTask(overrides: Partial): Task { + return { + id: "FN-A", + lineageId: "FN-A", + description: "", + column: "done", + dependencies: [], + steps: [], + currentStep: 0, + ...overrides, + } as Task; +} + +/* +FNXC:TaskRevert 2026-07-04-00:00 (FN-7547): +Real multi-repo git fixture coverage for the workspace revert path — this is +the Symptom Verification regression suite. Mirrors the scratch-repo fixture +pattern from workspace-merger-idempotency.test.ts and the single-repo +task-revert.real-git.test.ts, but with TWO sub-repos under a shared workspace +root so the all-or-nothing multi-repo classification/rollback contract can be +exercised for real. +*/ +describeIfGit("task-revert workspace real-git scenarios", { timeout: 30_000 }, () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function subRepoFixture(workspaceRoot: string, repoRel: string, initialFile: string, initialContent: string): string { + const repoRootDir = join(workspaceRoot, repoRel); + git(workspaceRoot, `mkdir -p ${repoRel}`); + git(repoRootDir, "git init -b main"); + git(repoRootDir, 'git config user.email "test@example.com"'); + git(repoRootDir, 'git config user.name "Test User"'); + git(repoRootDir, "git config commit.gpgsign false"); + writeFileSync(join(repoRootDir, initialFile), initialContent); + git(repoRootDir, `git add ${initialFile} && git commit -m 'init'`); + return repoRootDir; + } + + function workspaceFixture() { + const workspaceRoot = mkdtempSync(join(tmpdir(), "fn-7547-wsrevert-")); + dirs.push(workspaceRoot); + const repoA = subRepoFixture(workspaceRoot, "repo-a", "a.ts", "line1\n"); + const repoB = subRepoFixture(workspaceRoot, "repo-b", "b.ts", "line1\n"); + return { workspaceRoot, repoA, repoB }; + } + + function landTaskCommit(repoRootDir: string, file: string, content: string, commitSubject: string): string { + writeFileSync(join(repoRootDir, file), content); + git(repoRootDir, `git commit -am ${JSON.stringify(commitSubject)}`); + return git(repoRootDir, "git rev-parse HEAD"); + } + + function makeWorkspaceTask(shaA: string, shaB: string, overrides: Partial = {}): Task { + return makeTask({ + column: "done", + workspaceWorktrees: { + "repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA }, + "repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A", landedSha: shaB }, + }, + mergeDetails: { commitSha: shaA, workspaceLandedShas: { "repo-a": shaA, "repo-b": shaB } }, + ...overrides, + }); + } + + it("attribution: resolves the correct per-repo squash commit for each sub-repo", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const task = makeWorkspaceTask(shaA, shaB); + const attribution = await resolveWorkspaceTaskRevertCommits(task, { workspaceRootDir: workspaceRoot }); + + expect(Object.keys(attribution).sort()).toEqual(["repo-a", "repo-b"]); + expect(attribution["repo-a"]).toEqual({ commits: [shaA], source: "squash" }); + expect(attribution["repo-b"]).toEqual({ commits: [shaB], source: "squash" }); + }); + + it("attribution: falls back to lineage association when a repo's landed sha is absent", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaBUnrecorded = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const task = makeTask({ + column: "done", + workspaceWorktrees: { + "repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA }, + "repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A" }, + }, + mergeDetails: { commitSha: shaA, workspaceLandedShas: { "repo-a": shaA } }, + }); + + const attribution = await resolveWorkspaceTaskRevertCommits(task, { + workspaceRootDir: workspaceRoot, + commitAssociationSource: { + getTaskCommitAssociationsByLineageId: async () => [ + { + id: "assoc-1", + taskLineageId: "FN-A", + taskIdSnapshot: "FN-A", + commitSha: shaBUnrecorded, + commitSubject: "feat(FN-A): add feature in repo-b", + authoredAt: new Date().toISOString(), + matchedBy: "canonical-lineage-trailer", + confidence: "canonical", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }, + }); + + expect(attribution["repo-a"]).toEqual({ commits: [shaA], source: "squash" }); + expect(attribution["repo-b"]).toEqual({ commits: [shaBUnrecorded], source: "lineage" }); + }); + + it("clean all-or-nothing: reverts both sub-repos with a Fusion-Task-Id-trailered commit on each (Symptom Verification)", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} }); + + expect(result.mode).toBe("git"); + expect(result.clean).toBe(true); + if (result.mode === "git" && result.clean) { + expect(result.workspace.repos).toHaveLength(2); + const byRepo = Object.fromEntries(result.workspace.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"].classification).toBe("clean"); + expect(byRepo["repo-a"].revertCommitSha).toBeTruthy(); + expect(byRepo["repo-b"].classification).toBe("clean"); + expect(byRepo["repo-b"].revertCommitSha).toBeTruthy(); + } + + for (const repoRootDir of [repoA, repoB]) { + const subject = git(repoRootDir, "git log -1 --format=%s"); + expect(subject).toMatch(/^revert\(FN-A\):/); + const body = git(repoRootDir, "git log -1 --format=%B"); + expect(body).toContain("Fusion-Task-Id: FN-A"); + expect(git(repoRootDir, "git status --porcelain")).toBe(""); + } + expect(git(repoA, "git show HEAD:a.ts")).toBe("line1"); + expect(git(repoB, "git show HEAD:b.ts")).toBe("line1"); + }); + + it("partial-conflict rollback: a later task touching repo-b only leaves BOTH repos byte-identical to pre-call (Symptom Verification)", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + // Task B later modifies the exact same region touched by task A in repo-b only. + landTaskCommit(repoB, "b.ts", "line1\nfeature-a-modified-by-b\n", "feat(FN-B): modify same region in repo-b"); + + const preCallHeadA = git(repoA, "git rev-parse HEAD"); + const preCallStatusA = git(repoA, "git status --porcelain"); + const preCallHeadB = git(repoB, "git rev-parse HEAD"); + const preCallStatusB = git(repoB, "git status --porcelain"); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} }); + + expect(result.mode).toBe("git"); + expect(result.clean).toBe(false); + if (result.mode === "git" && !result.clean && "conflicts" in result) { + expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true); + } + + // NO commit created in EITHER repo — all-or-nothing rollback held. + expect(git(repoA, "git rev-parse HEAD")).toBe(preCallHeadA); + expect(git(repoA, "git status --porcelain")).toBe(preCallStatusA); + expect(git(repoB, "git rev-parse HEAD")).toBe(preCallHeadB); + expect(git(repoB, "git status --porcelain")).toBe(preCallStatusB); + // repo-a is NOT left reverted. + expect(git(repoA, "git show HEAD:a.ts")).toBe("line1\nfeature-a"); + }); + + it("already-reverted: reverting a clean task twice reports alreadyReverted for both repos with no second commit", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const task = makeWorkspaceTask(shaA, shaB); + const first = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} }); + expect(first.clean).toBe(true); + + const headAAfterFirst = git(repoA, "git rev-parse HEAD"); + const headBAfterFirst = git(repoB, "git rev-parse HEAD"); + + const second = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} }); + expect(second.mode).toBe("git"); + expect(second.clean).toBe(true); + if (second.mode === "git" && second.clean) { + for (const repo of second.workspace.repos) { + expect(repo.alreadyReverted).toBe(true); + } + } + + expect(git(repoA, "git rev-parse HEAD")).toBe(headAAfterFirst); + expect(git(repoB, "git rev-parse HEAD")).toBe(headBAfterFirst); + }); + + it("dirty-tree refusal: refuses without mutating either repo when one sub-repo has a stray uncommitted change", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a\nSTRAY UNCOMMITTED CHANGE\n"); + + const preCallHeadA = git(repoA, "git rev-parse HEAD"); + const preCallHeadB = git(repoB, "git rev-parse HEAD"); + + const task = makeWorkspaceTask(shaA, shaB); + await expect(revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} })).rejects.toMatchObject({ + code: "dirty-working-tree", + }); + + expect(git(repoA, "git rev-parse HEAD")).toBe(preCallHeadA); + expect(git(repoA, "git status --porcelain")).toBe(""); + expect(git(repoB, "git rev-parse HEAD")).toBe(preCallHeadB); + expect(git(repoB, "git show HEAD:b.ts")).toBe("line1\nfeature-a"); + }); + + it("guard rails: refuses a non-done/archived workspace task and never mutates the source task lifecycle", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const task = makeWorkspaceTask(shaA, shaB, { column: "in-progress" }); + const result = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} }); + + expect(result).toMatchObject({ mode: "git", needsHuman: true }); + expect(task.column).toBe("in-progress"); + }); + + it("guard rails: autoMerge:false refuses with needsHuman instead of force-writing", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await revertWorkspaceTask({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + effectiveAutoMerge: false, + }); + + expect(result).toMatchObject({ mode: "git", needsHuman: true }); + expect(git(repoA, "git status --porcelain")).toBe(""); + expect(git(repoB, "git status --porcelain")).toBe(""); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 520c17d92c..07df194aa3 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -270,6 +270,8 @@ export { resolveTaskRevertCommits, classifyTaskRevert, performTaskRevert, + resolveWorkspaceTaskRevertCommits, + revertWorkspaceTask, TaskRevertError, type TaskRevertCommitSource, type ResolvedTaskRevertCommits, @@ -286,6 +288,10 @@ export { type CreateAiUndoTaskDeps, type TaskRevertGranularity, type PerformTaskRevertOptions, + type WorkspaceRepoRevertCommits, + type WorkspaceRepoRevertResult, + type WorkspaceTaskRevertResult, + type RevertWorkspaceTaskOptions, } from "./task-revert.js"; export { resolveBranchGroupMergeRouting, diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts index 8481794217..e1c64537f6 100644 --- a/packages/engine/src/task-revert.ts +++ b/packages/engine/src/task-revert.ts @@ -11,8 +11,24 @@ * * 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. Multi-repo workspace-task revert is - * out of scope here (see `resolveTaskRevertCommits`'s workspace guard). + * 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 @@ -21,8 +37,11 @@ * `finally` block, regardless of how the dry-run terminates. */ import { exec } from "node:child_process"; +import { join } from "node:path"; import { promisify } from "node:util"; -import type { Task, TaskCommitAssociation, TaskCreateInput } from "@fusion/core"; +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; @@ -433,7 +452,7 @@ export type TaskRevertResult = export type TaskRevertGranularity = "squash" | "per-sha"; export interface PerformTaskRevertOptions { - task: Pick; + task: Pick; worktreePath: string; baseBranch: string; execAsyncImpl?: ExecAsyncImpl; @@ -477,6 +496,16 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise 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` }; } @@ -631,6 +660,417 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise } } +// --------------------------------------------------------------------------- +// 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); + } +} + +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 `) — 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, + opts: ResolveWorkspaceTaskRevertCommitsOptions, +): Promise> { + const execImpl = opts.execAsyncImpl ?? defaultExecAsync; + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const repoKeys = Object.keys(workspaceWorktrees).sort(); + const workspaceLandedShas = task.mergeDetails?.workspaceLandedShas ?? {}; + + const result: Record = {}; + + 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; + /** 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 `) 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 { + 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-7524: AI-undo fallback // ────────────────────────────────────────────────────────────────────────