From 53d7b7edcd13fe5dc6f52d01cd53454e074d5b18 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 19:26:31 -0700 Subject: [PATCH] FN-7523: add git-revert engine service and task revert API route Adds an intelligent git-revert service for done/archived tasks plus a single POST /api/tasks/:id/revert route, with tests and a changeset. - Add packages/engine/src/task-revert.ts exporting resolveTaskRevertCommits, classifyTaskRevert, and performTaskRevert (squash/rebase/lineage attribution precedence, dry-run classification, guaranteed-clean rollback) - Wire new revert exports into packages/engine/src/index.ts - Add POST /api/tasks/:id/revert route in register-task-workflow-routes.ts, enforcing done/archived-only and autoMerge-off guard rails; unresolved conflicting results are left for sibling FN-7524 (AI-undo) to act on; workspace tasks return unsupported - Add engine real-git revert tests (task-revert.real-git.test.ts) and dashboard route tests (task-revert-route.test.ts) - Document the revert capability in docs/task-management.md - Add .changeset/fn-7523-task-revert.md (@runfusion/fusion: patch) Files changed: .changeset/fn-7523-task-revert.md | 7 + docs/task-management.md | 8 + .../src/__tests__/task-revert-route.test.ts | 180 +++++++ .../src/routes/register-task-workflow-routes.ts | 75 ++- .../src/__tests__/task-revert.real-git.test.ts | 248 ++++++++++ packages/engine/src/index.ts | 14 + packages/engine/src/task-revert.ts | 524 +++++++++++++++++++++ 7 files changed, 1055 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7523 Fusion-Task-Lineage: ec349d4f-cc05-48d1-9e82-8c14d1470881 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7523-task-revert.md | 7 + docs/task-management.md | 8 + .../src/__tests__/task-revert-route.test.ts | 180 ++++++ .../routes/register-task-workflow-routes.ts | 75 ++- .../__tests__/task-revert.real-git.test.ts | 248 +++++++++ packages/engine/src/index.ts | 14 + packages/engine/src/task-revert.ts | 524 ++++++++++++++++++ 7 files changed, 1055 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7523-task-revert.md create mode 100644 packages/dashboard/src/__tests__/task-revert-route.test.ts create mode 100644 packages/engine/src/__tests__/task-revert.real-git.test.ts create mode 100644 packages/engine/src/task-revert.ts diff --git a/.changeset/fn-7523-task-revert.md b/.changeset/fn-7523-task-revert.md new file mode 100644 index 0000000000..4912dce4c3 --- /dev/null +++ b/.changeset/fn-7523-task-revert.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add an intelligent git-revert engine service and POST /api/tasks/:id/revert route. +category: feature +dev: New `packages/engine/src/task-revert.ts` exports `resolveTaskRevertCommits`, `classifyTaskRevert`, and `performTaskRevert` (squash/rebase/lineage attribution precedence, dry-run classification, guaranteed-clean rollback). Route enforces done/archived-only and autoMerge-off guard rails; conflicting results are returned unresolved for sibling FN-7524 (AI-undo) to act on. Workspace tasks return `unsupported`. diff --git a/docs/task-management.md b/docs/task-management.md index 8b64253357..b6dd2dc253 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -673,6 +673,14 @@ Recovery/backfill guidance: - If both the active row and archive snapshot were overwritten, Fusion cannot reconstruct lost attachments/comments automatically; recreate them from git history, branch/worktree contents, screenshots, or external issue trackers. - Record the incident in the replacement task so future audits understand why the task ID and commit history diverge. +## Reverting Done/Archived tasks (git path) + +- `POST /api/tasks/:id/revert` (FN-7523) reverts a **Done** or **Archived** task's landed work via git. Only `done`/`archived` tasks are revertable; the source task's column/status is never mutated as a side effect. +- The engine resolves the task's attributable commit(s) (squash single-commit, rebase/cherry-pick trailer-filtered subset, or lineage-snapshot fallback), performs a non-committing dry-run to classify the outcome, and only writes a real commit when the dry-run is clean. +- Response contract: `{ mode: "git", clean, revertCommitSha?, 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. A conflicting result creates no commit and leaves the tree/HEAD untouched — this is where a future AI-undo fallback (sibling task) can take over. +- Workspace (multi-repo) tasks and `autoMerge:false` projects are out of scope for the forced git write and return `unsupported`/`needsHuman` results instead. +- This is the git path only; no dashboard UI affordance ships with it (see sibling follow-up tasks for the card action and the AI-undo fallback). + ## 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 new file mode 100644 index 0000000000..78241e5481 --- /dev/null +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment node + +/* +FNXC:TaskRevert 2026-07-04-00:00: +API-level coverage for POST /tasks/:id/revert (FN-7523). The real git dry-run/ +classify/apply behavior is proven in packages/engine/src/__tests__/task-revert.real-git.test.ts — +this suite stubs `performTaskRevert` at the route boundary and asserts: + - the done/archived guard (4xx for other columns, before the engine service is even called); + - the response contract shapes for clean / alreadyReverted / conflicting outcomes; + - error mapping (TaskRevertError -> 409 for dirty-working-tree, 500 otherwise). +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; +import type { Task, TaskStore } from "@fusion/core"; +import { createApiRoutes } from "../routes.js"; +import { request as performRequest } from "../test-request.js"; + +// FNXC:TaskRevert 2026-07-04-00:00: the route now guards against `rootDir` +// (the shared user checkout) sitting on a branch other than the resolved +// base branch (see the branch-mismatch check in register-task-workflow-routes.ts). +// A real repo checked out on "main" (the integration-branch fallback with no +// `integrationBranch`/`baseBranch` setting) satisfies that guard for the +// success-path tests below. +function makeGitRepoOnMain(): string { + const dir = mkdtempSync(join(tmpdir(), "kb-task-revert-route-")); + execFileSync("git", ["init", "-b", "main"], { cwd: dir }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: dir }); + execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: dir }); + return dir; +} + +const performTaskRevertMock = vi.fn(); + +vi.mock("@fusion/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + performTaskRevert: (...args: unknown[]) => performTaskRevertMock(...args), + }; +}); + +function makeTask(overrides: Partial): Task { + return { + id: "FN-100", + lineageId: "FN-100", + description: "revert me", + column: "done", + dependencies: [], + steps: [], + currentStep: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +function createMockStore(task: Task): TaskStore { + return { + getSettings: vi.fn().mockResolvedValue({}), + getSettingsFast: vi.fn().mockResolvedValue({ autoMerge: true }), + getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()), + getTask: vi.fn().mockResolvedValue(task), + getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]), + on: vi.fn(), + off: vi.fn(), + } as unknown as TaskStore; +} + +function createApp(store: TaskStore) { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + return app; +} + +async function REQUEST(app: express.Express, method: string, path: string) { + return performRequest(app, method, path); +} + +describe("POST /tasks/:id/revert", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns a clean revert result for a done task", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ mode: "git", clean: true, revertCommitSha: "abc123" }); + + 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, revertCommitSha: "abc123" }); + expect(performTaskRevertMock).toHaveBeenCalledTimes(1); + }); + + it("returns an alreadyReverted result without invoking a second commit", async () => { + const task = makeTask({ column: "archived" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ mode: "git", clean: true, alreadyReverted: true }); + + 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, alreadyReverted: true }); + }); + + it("returns a conflicting result without creating an AI-undo follow-up task", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ + mode: "git", + clean: false, + conflicts: [{ file: "foo.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: "git", + clean: false, + conflicts: [{ file: "foo.ts", status: "UU" }], + }); + }); + + it("rejects a non-done/archived task with a 4xx guard before invoking the engine service", async () => { + const task = makeTask({ 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(String((res.body as { error?: string }).error ?? "")).toMatch(/done\/archived/i); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); + + it("returns 404 when the task does not exist", async () => { + const store = createMockStore(makeTask({})); + (store.getTask as ReturnType).mockResolvedValue(undefined); + + const res = await REQUEST(createApp(store), "POST", "/api/tasks/FN-999/revert"); + expect(res.status).toBe(404); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); + + it("maps a dirty-working-tree TaskRevertError to 409", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + const { TaskRevertError } = await import("@fusion/engine"); + performTaskRevertMock.mockRejectedValue(new TaskRevertError("working tree is dirty", "dirty-working-tree")); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(409); + }); + + it("maps an unexpected TaskRevertError to 500", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + const { TaskRevertError } = await import("@fusion/engine"); + performTaskRevertMock.mockRejectedValue(new TaskRevertError("git log failed", "git-log-failed")); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(500); + }); + + it("rejects with a branch-mismatch 409 when rootDir is checked out on a different branch than the resolved base branch, without invoking the engine service", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + const rootDir = (store.getRootDir as () => string)(); + execFileSync("git", ["checkout", "-b", "some-other-branch"], { cwd: rootDir }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(409); + expect((res.body as { details?: { code?: string } }).details?.code ?? (res.body as { error?: string }).error).toBeTruthy(); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 6b993bad21..2e5596762c 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -49,7 +49,7 @@ import { import { GitHubClient } from "../github.js"; import { createTrackingIssueForTask } from "../github-tracking-hook.js"; import { parseGitHubBadgeUrl } from "./register-git-github.js"; -import { planTaskWorktreePath, promoteHeldTask } from "@fusion/engine"; +import { planTaskWorktreePath, promoteHeldTask, performTaskRevert, TaskRevertError } 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"; @@ -1662,6 +1662,79 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + /* + FNXC:TaskRevert 2026-07-04-00:00: + POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523, foundation + for FN-7501). 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; + - the source task's column/status is NEVER mutated as a side effect of a revert. + Response contract: `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. + On conflict, this route does NOT create the AI-undo follow-up task — that is sibling FN-7524's + job; the UI/caller decides what to do with the conflict result. This route also never moves the + source task backward through its lifecycle. + */ + router.post("/tasks/:id/revert", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const task = await scopedStore.getTask(req.params.id); + if (!task) { + throw notFound(`Task ${req.params.id} not found`); + } + if (task.column !== "done" && task.column !== "archived") { + throw conflict(`Task ${task.id} is in column "${task.column}"; only done/archived tasks can be reverted`); + } + + const rootDir = scopedStore.getRootDir(); + const settings = await scopedStore.getSettingsFast(); + const baseBranch = task.mergeDetails?.mergeTargetBranch || await resolveIntegrationBranch(rootDir, settings); + + /* + FNXC:TaskRevert 2026-07-04-00:00: + `rootDir` (`scopedStore.getRootDir()`) is the SHARED user checkout, not a + dedicated per-task worktree — `computeExtendedGitStatus`'s `isOnIntegrationBranch` + handling and `pullGitBranch`'s integration-worktree branch-mismatch guard both + document that this checkout can legitimately be on any branch at any time (e.g. + a user mid-review on a feature branch). `performTaskRevert` mutates `worktreePath` + in place (dry-run revert + real commit on "the appropriate base branch"), so + without this check a revert requested while rootDir sits on a different branch + would silently apply/commit the revert onto THAT branch instead of `baseBranch` — + committing to the wrong branch is worse than refusing. Mirror `pullGitBranch`'s + `branch-mismatch` 409 contract here rather than assuming the caller pre-checked out. + */ + const currentBranch = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], rootDir, 5_000)).trim(); + if (currentBranch !== baseBranch) { + throw new ApiError(409, `Checkout is on "${currentBranch}", not the task's base branch "${baseBranch}"; switch to "${baseBranch}" before reverting`, { + code: "branch-mismatch", + currentBranch, + baseBranch, + }); + } + + const result = await performTaskRevert({ + task, + worktreePath: rootDir, + baseBranch, + commitAssociationSource: { + getTaskCommitAssociationsByLineageId: (lineageId: string) => + scopedStore.getTaskCommitAssociationsByLineageId(lineageId), + }, + effectiveAutoMerge: settings.autoMerge, + }); + + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof TaskRevertError) { + const status = err.code === "dirty-working-tree" ? 409 : 500; + throw new ApiError(status, err.message, { code: err.code }); + } + rethrowAsApiError(err); + } + }); + // Retry failed, stuck-killed, or stranded triage/planning task router.post("/tasks/:id/retry", async (req, res) => { try { diff --git a/packages/engine/src/__tests__/task-revert.real-git.test.ts b/packages/engine/src/__tests__/task-revert.real-git.test.ts new file mode 100644 index 0000000000..99c96b9fb2 --- /dev/null +++ b/packages/engine/src/__tests__/task-revert.real-git.test.ts @@ -0,0 +1,248 @@ +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 { + classifyTaskRevert, + performTaskRevert, + resolveTaskRevertCommits, + TaskRevertError, +} from "../task-revert.js"; +import type { Task, TaskCommitAssociation } 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-901", + lineageId: "FN-901", + description: "", + column: "done", + dependencies: [], + steps: [], + currentStep: 0, + ...overrides, + } as Task; +} + +// FN-7523: real-git regression coverage for the intelligent revert service — +// attribution resolution, dry-run classification (already-reverted/clean/conflicting), +// clean-apply commit creation, guaranteed-clean rollback, and guard rails. +describeIfGit("task-revert real-git scenarios", { timeout: 30_000 }, () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function repoFixture() { + const repo = mkdtempSync(join(tmpdir(), "fn-7523-revert-")); + dirs.push(repo); + git(repo, "git init -b main"); + git(repo, 'git config user.email "test@example.com"'); + git(repo, 'git config user.name "Test User"'); + git(repo, "git config commit.gpgsign false"); + writeFileSync(join(repo, "foo.ts"), "line1\n"); + git(repo, "git add foo.ts && git commit -m 'init'"); + return repo; + } + + it("attribution: squash task resolves the single commitSha", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const sha = git(repo, "git rev-parse HEAD"); + + const task = makeTask({ mergeDetails: { commitSha: sha } }); + const resolved = await resolveTaskRevertCommits(task, { worktreePath: repo }); + expect(resolved.supported).toBe(true); + if (resolved.supported) { + expect(resolved.shas).toEqual([sha]); + expect(resolved.source).toBe("squash"); + } + }); + + it("attribution: rebase task resolves the attributable subset by trailer", async () => { + const repo = repoFixture(); + const rebaseBase = git(repo, "git rev-parse HEAD"); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git commit -am 'feat(FN-901): part 1'"); + writeFileSync(join(repo, "other.ts"), "unrelated\n"); + git(repo, "git add other.ts && git commit -m 'chore: unrelated foreign commit'"); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\nfeature-a-more\n"); + git(repo, "git commit -am 'more work' -m 'Fusion-Task-Id: FN-901'"); + const head = git(repo, "git rev-parse HEAD"); + + const task = makeTask({ mergeDetails: { commitSha: head, rebaseBaseSha: rebaseBase } }); + const resolved = await resolveTaskRevertCommits(task, { worktreePath: repo }); + expect(resolved.supported).toBe(true); + if (resolved.supported) { + expect(resolved.shas.length).toBe(2); + expect(resolved.source).toBe("rebase"); + } + }); + + it("attribution: lineage fallback used when mergeDetails absent", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const sha = git(repo, "git rev-parse HEAD"); + + const task = makeTask({ mergeDetails: undefined }); + const associations: TaskCommitAssociation[] = [ + { + id: "assoc-1", + taskLineageId: "FN-901", + taskIdSnapshot: "FN-901", + commitSha: sha, + commitSubject: "feat(FN-901): add feature a", + authoredAt: new Date().toISOString(), + matchedBy: "canonical-lineage-trailer", + confidence: "canonical", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]; + const resolved = await resolveTaskRevertCommits(task, { + worktreePath: repo, + commitAssociationSource: { + getTaskCommitAssociationsByLineageId: async () => associations, + }, + }); + expect(resolved.supported).toBe(true); + if (resolved.supported) { + expect(resolved.shas).toEqual([sha]); + expect(resolved.source).toBe("lineage"); + } + }); + + it("workspace unsupported: task with workspaceLandedShas is rejected", async () => { + const repo = repoFixture(); + const task = makeTask({ + mergeDetails: { commitSha: "deadbeef", workspaceLandedShas: { "repo-a": "deadbeef" } }, + }); + const resolved = await resolveTaskRevertCommits(task, { worktreePath: repo }); + expect(resolved.supported).toBe(false); + if (!resolved.supported) { + expect(resolved.reason).toBe("workspace-task-revert-unsupported"); + } + }); + + it("clean revert: creates a revert(FN-xxxx) commit with Fusion-Task-Id trailer and reverts file content", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const sha = git(repo, "git rev-parse HEAD"); + + const task = makeTask({ column: "done", mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" }); + expect(result).toMatchObject({ mode: "git", clean: true }); + if (result.mode === "git" && result.clean && "revertCommitSha" in result) { + expect(result.revertCommitSha).toBeTruthy(); + } + + const log = git(repo, "git log -1 --format=%s%n%B"); + expect(log).toMatch(/^revert\(FN-901\):/); + const fullBody = git(repo, "git log -1 --format=%B"); + expect(fullBody).toContain("Fusion-Task-Id: FN-901"); + + const content = git(repo, "git show HEAD:foo.ts"); + expect(content).toBe("line1"); + + const status = git(repo, "git status --porcelain"); + expect(status).toBe(""); + }); + + it("conflict detection: a later task touching the same region classifies as conflicting and leaves tree+HEAD untouched", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const shaA = git(repo, "git rev-parse HEAD"); + + // Task B later modifies the exact same region touched by task A. + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a-modified-by-b\n"); + git(repo, "git commit -am 'feat(FN-902): modify same region'"); + + const preCallHead = git(repo, "git rev-parse HEAD"); + const preCallStatus = git(repo, "git status --porcelain"); + + const task = makeTask({ id: "FN-901", column: "done", mergeDetails: { commitSha: shaA, mergeTargetBranch: "main" } }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" }); + + expect(result).toMatchObject({ mode: "git", clean: false }); + if (result.mode === "git" && !result.clean && "conflicts" in result) { + expect(result.conflicts.length).toBeGreaterThan(0); + expect(result.conflicts.some((c) => c.file === "foo.ts")).toBe(true); + } + + const postCallHead = git(repo, "git rev-parse HEAD"); + const postCallStatus = git(repo, "git status --porcelain"); + expect(postCallHead).toBe(preCallHead); + expect(postCallStatus).toBe(preCallStatus); + expect(postCallStatus).toBe(""); + }); + + it("already-reverted / no-op: reverting a task twice reports alreadyReverted without a second commit", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const sha = git(repo, "git rev-parse HEAD"); + + const task = makeTask({ column: "done", mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } }); + const first = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" }); + expect(first).toMatchObject({ mode: "git", clean: true }); + + const headAfterFirst = git(repo, "git rev-parse HEAD"); + const second = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" }); + expect(second).toMatchObject({ mode: "git", clean: true, alreadyReverted: true }); + + const headAfterSecond = git(repo, "git rev-parse HEAD"); + expect(headAfterSecond).toBe(headAfterFirst); + }); + + it("dirty-tree refusal: refuses without mutating the tree when a stray change is staged", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const sha = git(repo, "git rev-parse HEAD"); + + writeFileSync(join(repo, "stray.txt"), "stray change\n"); + git(repo, "git add stray.txt"); + + const preStatus = git(repo, "git status --porcelain"); + await expect(classifyTaskRevert({ worktreePath: repo, commits: [sha] })).rejects.toThrow(TaskRevertError); + const postStatus = git(repo, "git status --porcelain"); + expect(postStatus).toBe(preStatus); + }); + + it("guard rails: a non-done/archived task is rejected and the source task's column is unaffected", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const sha = git(repo, "git rev-parse HEAD"); + + const task = makeTask({ column: "in-progress", mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" }); + expect(result).toMatchObject({ mode: "git", needsHuman: true }); + // Column is a property of the caller-owned task object, not mutated by the service. + expect(task.column).toBe("in-progress"); + }); + + it("guard rails: autoMerge:false returns a needsHuman result instead of force-writing", async () => { + const repo = repoFixture(); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git add foo.ts && git commit -m 'feat(FN-901): add feature a'"); + const sha = git(repo, "git rev-parse HEAD"); + const preHead = git(repo, "git rev-parse HEAD"); + + const task = makeTask({ column: "done", autoMerge: false, mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" }); + expect(result).toMatchObject({ mode: "git", needsHuman: true }); + expect(git(repo, "git rev-parse HEAD")).toBe(preHead); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index c3ca808a06..44eead5ee3 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -266,6 +266,20 @@ export { resolveIntegrationBranchSync, __resetIntegrationBranchCacheForTests, } from "./integration-branch.js"; +export { + resolveTaskRevertCommits, + classifyTaskRevert, + performTaskRevert, + TaskRevertError, + type TaskRevertCommitSource, + type ResolvedTaskRevertCommits, + type UnsupportedTaskRevert, + type TaskRevertClassification, + type TaskRevertConflict, + type ClassifyTaskRevertResult, + type TaskRevertResult, + type TaskCommitAssociationSource, +} from "./task-revert.js"; export { resolveBranchGroupMergeRouting, evaluateBranchGroupPromotion, diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts new file mode 100644 index 0000000000..74edee2bbb --- /dev/null +++ b/packages/engine/src/task-revert.ts @@ -0,0 +1,524 @@ +/** + * FNXC:TaskRevert 2026-07-04-00:00: + * Intelligent git-revert service (FN-7523, foundation for FN-7501). Given a + * done/archived task, this module: + * 1. Resolves the set of commits attributable to that task (squash / rebase + * / lineage-snapshot precedence — see `resolveTaskRevertCommits`). + * 2. Performs a NON-committing dry-run revert to classify the outcome as + * already-reverted / clean / conflicting (see `classifyTaskRevert`). + * 3. When clean, creates the real revert commit(s) with a `Fusion-Task-Id` + * trailer on the resolved base branch (see `performTaskRevert`). + * + * This is the git path ONLY. Conflicting reverts are handed back to the + * caller/UI unresolved — the AI-undo fallback is sibling task FN-7524, and the + * UI affordance is sibling task FN-7525. Multi-repo workspace-task revert is + * out of scope here (see `resolveTaskRevertCommits`'s workspace guard). + * + * Safety invariant (the core contract of this module): the working tree and + * index are NEVER left dirty on any failure path. `classifyTaskRevert` always + * captures `preRevertHead` before touching the tree and guarantees a full + * `git revert --abort` + `git reset --hard ` rollback in a + * `finally` block, regardless of how the dry-run terminates. + */ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { Task, TaskCommitAssociation } from "@fusion/core"; + +const defaultExecAsync = promisify(exec); +type ExecAsyncImpl = typeof defaultExecAsync; + +const GIT_TIMEOUT_MS = 30_000; +const GIT_MAX_BUFFER = 10 * 1024 * 1024; + +/** Minimal store surface this module depends on — keeps task-revert.ts test-friendly without pulling in the full TaskStore type. */ +export interface TaskCommitAssociationSource { + getTaskCommitAssociationsByLineageId(lineageId: string): Promise; +} + +export class TaskRevertError extends Error { + readonly code: string; + readonly cause?: unknown; + + constructor(message: string, code: string, cause?: unknown) { + super(message); + this.name = "TaskRevertError"; + this.code = code; + this.cause = cause; + } +} + +function quoteShellArg(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +// FNXC:TaskRevert 2026-07-04-00:00: +// Reuse branch-attribution.ts's trailer/subject parsing so revert attribution +// stays consistent with merge-time attribution. Duplicated locally (rather +// than imported) because branch-attribution.ts's helpers are module-private; +// the regex/precedence MUST stay identical to branch-attribution.ts's +// `extractAttributedTaskId` / `extractTaskIdFromSubject` — update both together. +function extractAttributedTaskId(body: string): string | null { + const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim; + let match: RegExpExecArray | null = null; + let last: RegExpExecArray | null = null; + while (true) { + match = trailerPattern.exec(body); + if (!match) break; + last = match; + } + return last?.[1] ?? null; +} + +function extractTaskIdFromSubject(subject: string): string | null { + if (!subject) return null; + const conventional = + /^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject); + if (conventional?.[1]) return conventional[1].toUpperCase(); + const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject); + if (bracketed?.[1]) return bracketed[1].toUpperCase(); + const colon = /^\s*([A-Z]+-\d+):/i.exec(subject); + if (colon?.[1]) return colon[1].toUpperCase(); + return null; +} + +function taskIdsMatch(a: string | null, b: string): boolean { + if (!a) return false; + return a.toUpperCase() === b.toUpperCase(); +} + +async function runGit( + execImpl: ExecAsyncImpl, + command: string, + cwd: string, +): Promise<{ stdout: string; stderr: string }> { + const result = await execImpl(command, { + cwd, + encoding: "utf-8", + timeout: GIT_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER, + }); + return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") }; +} + +export type TaskRevertCommitSource = "squash" | "rebase" | "lineage" | "none"; + +export interface ResolvedTaskRevertCommits { + supported: true; + /** Attributable commit SHAs, newest first — reverting in this order applies the oldest change last, avoiding unnecessary self-conflicts among a task's own commits. */ + shas: string[]; + source: TaskRevertCommitSource; +} + +export interface UnsupportedTaskRevert { + supported: false; + reason: string; +} + +export interface ResolveTaskRevertCommitsOptions { + worktreePath: string; + execAsyncImpl?: ExecAsyncImpl; + /** Lineage-snapshot fallback source (typically the scoped TaskStore). Optional so callers that already know mergeDetails is present can omit it. */ + commitAssociationSource?: TaskCommitAssociationSource; +} + +/** + * FNXC:TaskRevert 2026-07-04-00:00: + * Attribution precedence (mirrors merge-time attribution capture): + * 1. Squash strategy — `mergeDetails.commitSha` alone when `rebaseBaseSha` + * is unset (single squash commit landed the task). + * 2. Rebase/cherry-pick strategy — the task-attributable subset of + * `rebaseBaseSha..commitSha`, filtered by `Fusion-Task-Id` trailer or + * conventional-commit subject. Falls back to the full range endpoint + * (`commitSha`) when no per-commit attribution is possible (foreign + * commits interleaved with no trailer/subject match — better to revert + * the endpoint than nothing). + * 3. Lineage snapshot fallback — `TaskCommitAssociation` rows keyed by + * `taskLineageId`, used when `mergeDetails` is absent/incomplete (e.g. + * legacy tasks merged before mergeDetails was captured). + * + * FNXC:TaskRevert 2026-07-04-00:00 (workspace limitation): + * Workspace tasks (`mergeDetails.workspaceLandedShas` present) land commits + * across MULTIPLE sub-repo integration branches with no single coherent + * revert target — reverting one sub-repo's commit without reasoning about the + * others could leave the workspace in a half-reverted, inconsistent state. + * This is explicitly out of scope for FN-7523; the caller should route + * workspace tasks to the AI-undo fallback (FN-7524) instead. + */ +export async function resolveTaskRevertCommits( + task: Pick, + opts: ResolveTaskRevertCommitsOptions, +): Promise { + const execImpl = opts.execAsyncImpl ?? defaultExecAsync; + const mergeDetails = task.mergeDetails; + + if (mergeDetails?.workspaceLandedShas && Object.keys(mergeDetails.workspaceLandedShas).length > 0) { + return { supported: false, reason: "workspace-task-revert-unsupported" }; + } + + if (mergeDetails?.commitSha) { + if (!mergeDetails.rebaseBaseSha) { + // Squash strategy: the single recorded commit is the entire landed change. + return { supported: true, shas: [mergeDetails.commitSha], source: "squash" }; + } + + // Rebase/cherry-pick strategy: filter the range to this task's own commits. + const rangeRef = `${mergeDetails.rebaseBaseSha}..${mergeDetails.commitSha}`; + let logOutput: string; + try { + const { stdout } = await runGit( + execImpl, + `git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(rangeRef)}`, + opts.worktreePath, + ); + logOutput = stdout; + } catch (error) { + throw new TaskRevertError(`git log failed for range ${rangeRef}`, "git-log-failed", error); + } + + const ownCommitShas: string[] = []; + const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean); + for (const record of records) { + const [sha = "", subject = "", ...bodyParts] = record.split("\x00"); + if (!sha) continue; + const body = bodyParts.join("\x00"); + const trailerAttributedTaskId = extractAttributedTaskId(body); + const attributedTaskId = trailerAttributedTaskId ?? extractTaskIdFromSubject(subject); + if (taskIdsMatch(attributedTaskId, task.id)) { + ownCommitShas.push(sha); + } + } + + if (ownCommitShas.length > 0) { + // `git log` without `--reverse` already yields newest-first order. + return { supported: true, shas: ownCommitShas, source: "rebase" }; + } + + // No per-commit attribution possible (foreign commits interleaved with no + // trailer/subject match) — fall back to reverting the full range endpoint. + return { supported: true, shas: [mergeDetails.commitSha], source: "rebase" }; + } + + // mergeDetails absent/incomplete — fall back to the lineage-snapshot association table. + const lineageId = task.lineageId ?? task.id; + if (!opts.commitAssociationSource) { + return { supported: true, shas: [], source: "none" }; + } + const associations = await opts.commitAssociationSource.getTaskCommitAssociationsByLineageId(lineageId); + if (associations.length === 0) { + return { supported: true, shas: [], source: "none" }; + } + // Rows are already ordered `authoredAt DESC, createdAt DESC` (newest first) by the store query. + return { supported: true, shas: associations.map((a) => a.commitSha), source: "lineage" }; +} + +export type TaskRevertClassification = "already-reverted" | "clean" | "conflicting"; + +export interface TaskRevertConflict { + file: string; + /** Raw `git status --porcelain` two-letter status code for the conflicted file (e.g. "UU", "AA"). */ + status?: string; +} + +export interface ClassifyTaskRevertResult { + classification: TaskRevertClassification; + conflicts?: TaskRevertConflict[]; + alreadyReverted?: boolean; +} + +export interface ClassifyTaskRevertOptions { + worktreePath: string; + /** Attributable commit SHAs, newest first (see `resolveTaskRevertCommits`). */ + commits: string[]; + execAsyncImpl?: ExecAsyncImpl; +} + +async function getUnmergedFiles( + execImpl: ExecAsyncImpl, + worktreePath: string, +): Promise { + const { stdout } = await runGit(execImpl, "git status --porcelain", worktreePath); + const conflicts: TaskRevertConflict[] = []; + for (const line of stdout.split("\n")) { + if (!line.trim()) continue; + const status = line.slice(0, 2); + // Unmerged states per `git status --porcelain`: UU, AA, DD, AU, UA, UD, DU. + if (/^(UU|AA|DD|AU|UA|UD|DU)$/.test(status)) { + conflicts.push({ file: line.slice(3).trim(), status }); + } + } + return conflicts; +} + +/** + * FNXC:TaskRevert 2026-07-04-00:00: + * Rollback safety contract (the core invariant of this service): capture + * `preRevertHead` BEFORE any git mutation. If the working tree is dirty at + * entry, refuse immediately without touching anything. Otherwise, run the + * dry-run revert sequence; regardless of outcome (clean, conflicting, or an + * unexpected error), the `finally` block runs `git revert --abort` + * (best-effort) THEN `git reset --hard ` so the tree/index are + * byte-identical to the pre-call state. This function NEVER commits and NEVER + * throws without first completing that rollback. + */ +export async function classifyTaskRevert(opts: ClassifyTaskRevertOptions): Promise { + const execImpl = opts.execAsyncImpl ?? defaultExecAsync; + const { worktreePath, commits } = opts; + + if (commits.length === 0) { + return { classification: "already-reverted", alreadyReverted: true }; + } + + let preRevertHead: string; + try { + const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath); + preRevertHead = stdout.trim(); + } catch (error) { + throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed", error); + } + if (!preRevertHead) { + throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed"); + } + + const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", worktreePath); + if (statusOut.trim().length > 0) { + throw new TaskRevertError( + "working tree is dirty; refusing to attempt a revert dry-run", + "dirty-working-tree", + ); + } + + let mutated = false; + let allAlreadyReverted = true; + const conflicts: TaskRevertConflict[] = []; + + try { + for (const sha of commits) { + mutated = true; + const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; + try { + await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath); + // FNXC:TaskRevert 2026-07-04-00:00: `git revert --no-commit` on an + // already-reverted commit exits 0 with NO staged/working-tree diff + // (no error, no "nothing to commit" text — that message only ever + // appears from a *subsequent* `git commit` attempt). Detect this by + // diffing `git status --porcelain` before/after the call: if nothing + // changed, this sha is a no-op; `--quit` clears the sequencer's + // in-progress marker WITHOUT touching any diff staged by earlier shas + // in this same batch (unlike `--abort`, which would reset everything). + const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; + if (statusAfter === statusBefore) { + await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); + continue; + } + allAlreadyReverted = false; + } catch (error) { + const stderr = + typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string" + ? (error as { stderr: string }).stderr + : ""; + const stdout = + typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string" + ? (error as { stdout: string }).stdout + : ""; + const combined = `${stdout}\n${stderr}`; + const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath); + if (unmergedFiles.length > 0) { + conflicts.push(...unmergedFiles); + allAlreadyReverted = false; + break; + } + // "nothing to commit" / empty-revert signal: this commit's change is + // already reflected as reverted at HEAD — treat as a no-op and continue. + if (/nothing to commit|no changes|empty commit/i.test(combined)) { + await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); + continue; + } + throw new TaskRevertError(`git revert --no-commit failed unexpectedly for ${sha}`, "revert-dry-run-failed", error); + } + } + } finally { + if (mutated) { + await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined); + await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined); + } + } + + if (conflicts.length > 0) { + return { classification: "conflicting", conflicts }; + } + if (allAlreadyReverted) { + return { classification: "already-reverted", alreadyReverted: true }; + } + return { classification: "clean" }; +} + +export type TaskRevertResult = + | { mode: "git"; clean: true; revertCommitSha: string } + | { mode: "git"; clean: true; alreadyReverted: true } + | { mode: "git"; clean: false; conflicts: TaskRevertConflict[] } + | { mode: "git"; unsupported: true; reason: string } + | { mode: "git"; needsHuman: true; reason: string }; + +export interface PerformTaskRevertOptions { + task: Pick; + worktreePath: string; + baseBranch: string; + execAsyncImpl?: ExecAsyncImpl; + commitAssociationSource?: TaskCommitAssociationSource; + /** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */ + effectiveAutoMerge?: boolean; +} + +// FNXC:TaskRevert 2026-07-04-00:00 (guard rails, enforced in BOTH the service +// and the route per PROMPT Step 3): only done/archived tasks are revertable. +const REVERTABLE_COLUMNS = new Set(["done", "archived"]); + +/** + * FNXC:TaskRevert 2026-07-04-00:00 (commit message/trailer contract): + * The revert commit's subject is `revert(FN-xxxx): ` (the + * original task's id + a short summary derived from the reverted commit's + * subject), with a body carrying `Fusion-Task-Id: FN-xxxx` (the ORIGINAL + * task id, so `extractAttributedTaskId` continues to resolve attribution back + * to the reverted task) and a `Reverts work landed by task FN-xxxx ().` + * line for human audit. This mirrors the commit-message conventions in + * AGENTS.md (task-id-prefixed subjects, `Fusion-Task-Id` trailer). + * + * FNXC:TaskRevert 2026-07-04-00:00 (guard rails): + * - Only `done`/`archived` tasks may be reverted (checked here AND at the API + * route layer — defense in depth). + * - When `autoMerge` is effectively off for this task, this function refuses + * with a `needsHuman` result instead of force-writing a revert commit onto + * a branch the project has opted out of automated writes to. + * - This function NEVER mutates the source task's store row/column — reverting + * is a forward-only git operation on the base branch, not a lifecycle move. + */ +export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise { + // FNXC:TaskRevert 2026-07-04-00:00: `baseBranch` is part of the stable + // caller-facing contract (the route resolves it via mergeTargetBranch / + // the integration-branch resolver) but is not read here directly — the + // caller is responsible for ensuring `worktreePath` is checked out at that + // branch's HEAD before invoking this function; kept as a named, documented + // parameter (not silently dropped) for FN-7524/FN-7525 call-site clarity. + const { task, worktreePath, baseBranch: _baseBranch } = opts; + const execImpl = opts.execAsyncImpl ?? defaultExecAsync; + + if (!REVERTABLE_COLUMNS.has(task.column)) { + return { mode: "git", needsHuman: true, reason: `task is in column "${task.column}"; only done/archived tasks are revertable` }; + } + + const effectiveAutoMerge = task.autoMerge ?? opts.effectiveAutoMerge ?? true; + if (effectiveAutoMerge === false) { + return { mode: "git", needsHuman: true, reason: "autoMerge is disabled for this task/project; refusing to force-write a revert commit" }; + } + + const resolved = await resolveTaskRevertCommits(task, { + worktreePath, + execAsyncImpl: execImpl, + commitAssociationSource: opts.commitAssociationSource, + }); + if (!resolved.supported) { + return { mode: "git", unsupported: true, reason: resolved.reason }; + } + + const classification = await classifyTaskRevert({ + worktreePath, + commits: resolved.shas, + execAsyncImpl: execImpl, + }); + + if (classification.classification === "already-reverted") { + return { mode: "git", clean: true, alreadyReverted: true }; + } + if (classification.classification === "conflicting") { + return { mode: "git", clean: false, conflicts: classification.conflicts ?? [] }; + } + + // classification === "clean" — perform the real (committing) revert. + let preRevertHead: string; + try { + const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath); + preRevertHead = stdout.trim(); + } catch (error) { + throw new TaskRevertError("failed to resolve HEAD before applying revert", "head-resolve-failed", error); + } + + let mutated = false; + let anyStaged = false; + try { + for (const sha of resolved.shas) { + mutated = true; + const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; + try { + await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath); + // FNXC:TaskRevert 2026-07-04-00:00: mirror classifyTaskRevert's + // status-diff no-op detection here — `git revert --no-commit` on an + // already-reverted commit exits 0 with no staged diff (no thrown + // error, no "nothing to commit" text on this call). `--quit` clears + // the sequencer marker without disturbing diff staged by earlier + // shas in this batch. + const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; + if (statusAfter === statusBefore) { + await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); + continue; + } + anyStaged = true; + } catch (error) { + const stderr = + typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string" + ? (error as { stderr: string }).stderr + : ""; + const stdout = + typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string" + ? (error as { stdout: string }).stdout + : ""; + if (/nothing to commit|no changes|empty commit/i.test(`${stdout}\n${stderr}`)) { + await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); + continue; + } + // The dry-run already proved this is clean; a live conflict here means + // the branch moved between classify and apply. Roll back and report conflicting. + const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath); + await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined); + await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined); + return { mode: "git", clean: false, conflicts: unmergedFiles }; + } + } + + if (!anyStaged) { + // Defensive: every sha in this batch turned out to be a no-op during the + // apply pass even though classify saw at least one real change (branch + // moved between classify and apply, or a race). Nothing to commit — + // report already-reverted rather than attempting an empty commit. + return { mode: "git", clean: true, alreadyReverted: true }; + } + + let originalSubject = ""; + try { + const { stdout } = await runGit(execImpl, `git log -1 --format=%s ${quoteShellArg(resolved.shas[0] ?? "HEAD")}`, worktreePath); + originalSubject = stdout.trim(); + } catch { + originalSubject = ""; + } + + const shortSummary = originalSubject.replace(/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style)\([^)]*\):\s*/i, "").slice(0, 72) || "revert landed changes"; + const subject = `revert(${task.id}): ${shortSummary}`; + const referencedSha = resolved.shas[0] ?? "unknown"; + const body1 = `Fusion-Task-Id: ${task.id}`; + const body2 = `Reverts work landed by task ${task.id} (${originalSubject || referencedSha} @ ${referencedSha.slice(0, 8)}).`; + + await runGit( + execImpl, + `git commit -m ${quoteShellArg(subject)} -m ${quoteShellArg(body1)} -m ${quoteShellArg(body2)}`, + worktreePath, + ); + + const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath); + return { mode: "git", clean: 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); + } +}