FN-7547: support reverting multi-repo workspace tasks via git
Adds all-or-nothing git-revert support for multi-repo workspace tasks and wires it into the existing revert route/AI-undo/per-sha machinery. - Add `resolveWorkspaceTaskRevertCommits` and `revertWorkspaceTask` to `packages/engine/src/task-revert.ts`, dry-run classifying every sub-repo first and only committing per-repo revert commits when every sub-repo is clean/already-reverted; any conflicting sub-repo rolls back every already-committed sub-repo. - Extract shared `applyAndCommitRevert" apply/commit machinery (built on the existing `applyRevertNoCommit` primitive) so the workspace path reuses the same commit-message/trailer contract as the single-repo path. - Add a defensive `isWorkspaceTask` guard to `performTaskRevert` so workspace tasks can never be silently reverted through the single-repo path. - Wire `POST /api/tasks/:id/revert` (register-task-workflow-routes.ts) to dispatch workspace tasks to `revertWorkspaceTask`, preserving the existing `mode` (git/ai/auto) and AI-undo-fallback contract for workspace conflicts. - Export the new workspace revert types/functions from `packages/engine/src/index.ts`. - Add route-dispatch and real-git workspace revert test coverage; update docs and add a changeset. Files changed: .changeset/fn-7547-workspace-task-revert.md | 7 + docs/task-management.md | 8 +- packages/dashboard/src/__tests__/task-revert-route.test.ts | 102 +++++ packages/dashboard/src/routes/register-task-workflow-routes.ts | 76 +++- packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts | 272 +++++++++++++ packages/engine/src/index.ts | 6 + packages/engine/src/task-revert.ts | 448 ++++++++++++++++++++- 7 files changed, 897 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-7547 Fusion-Task-Lineage: b1b5eeda-06fd-43c1-8163-74b62c77b000 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7547-workspace-task-revert.md
Normal file
7
.changeset/fn-7547-workspace-task-revert.md
Normal file
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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<typeof import("@fusion/engine")>();
|
||||
return {
|
||||
...actual,
|
||||
performTaskRevert: (...args: unknown[]) => performTaskRevertMock(...args),
|
||||
revertWorkspaceTask: (...args: unknown[]) => revertWorkspaceTaskMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -64,6 +66,22 @@ function makeTask(overrides: Partial<Task>): 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>): 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>): 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> = {}): 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("");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "id" | "lineageId" | "column" | "mergeDetails" | "autoMerge" | "userPaused" | "paused">;
|
||||
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "autoMerge" | "userPaused" | "paused" | "workspaceWorktrees">;
|
||||
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 <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-7524: AI-undo fallback
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user