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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 19:26:31 -07:00
parent 62ddb19cb3
commit 53d7b7edcd
7 changed files with 1055 additions and 1 deletions

View File

@@ -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`.

View File

@@ -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`.

View File

@@ -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<typeof import("@fusion/engine")>();
return {
...actual,
performTaskRevert: (...args: unknown[]) => performTaskRevertMock(...args),
};
});
function makeTask(overrides: Partial<Task>): 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<typeof vi.fn>).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();
});
});

View File

@@ -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 {

View File

@@ -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>): 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);
});
});

View File

@@ -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,

View File

@@ -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 <preRevertHead>` 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<TaskCommitAssociation[]>;
}
export class TaskRevertError extends Error {
readonly code: string;
readonly cause?: unknown;
constructor(message: string, code: string, cause?: unknown) {
super(message);
this.name = "TaskRevertError";
this.code = code;
this.cause = cause;
}
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// FNXC:TaskRevert 2026-07-04-00:00:
// Reuse branch-attribution.ts's trailer/subject parsing so revert attribution
// stays consistent with merge-time attribution. Duplicated locally (rather
// than imported) because branch-attribution.ts's helpers are module-private;
// the regex/precedence MUST stay identical to branch-attribution.ts's
// `extractAttributedTaskId` / `extractTaskIdFromSubject` — update both together.
function extractAttributedTaskId(body: string): string | null {
const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim;
let match: RegExpExecArray | null = null;
let last: RegExpExecArray | null = null;
while (true) {
match = trailerPattern.exec(body);
if (!match) break;
last = match;
}
return last?.[1] ?? null;
}
function extractTaskIdFromSubject(subject: string): string | null {
if (!subject) return null;
const conventional =
/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject);
if (conventional?.[1]) return conventional[1].toUpperCase();
const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject);
if (bracketed?.[1]) return bracketed[1].toUpperCase();
const colon = /^\s*([A-Z]+-\d+):/i.exec(subject);
if (colon?.[1]) return colon[1].toUpperCase();
return null;
}
function taskIdsMatch(a: string | null, b: string): boolean {
if (!a) return false;
return a.toUpperCase() === b.toUpperCase();
}
async function runGit(
execImpl: ExecAsyncImpl,
command: string,
cwd: string,
): Promise<{ stdout: string; stderr: string }> {
const result = await execImpl(command, {
cwd,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") };
}
export type TaskRevertCommitSource = "squash" | "rebase" | "lineage" | "none";
export interface ResolvedTaskRevertCommits {
supported: true;
/** Attributable commit SHAs, newest first — reverting in this order applies the oldest change last, avoiding unnecessary self-conflicts among a task's own commits. */
shas: string[];
source: TaskRevertCommitSource;
}
export interface UnsupportedTaskRevert {
supported: false;
reason: string;
}
export interface ResolveTaskRevertCommitsOptions {
worktreePath: string;
execAsyncImpl?: ExecAsyncImpl;
/** Lineage-snapshot fallback source (typically the scoped TaskStore). Optional so callers that already know mergeDetails is present can omit it. */
commitAssociationSource?: TaskCommitAssociationSource;
}
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* Attribution precedence (mirrors merge-time attribution capture):
* 1. Squash strategy — `mergeDetails.commitSha` alone when `rebaseBaseSha`
* is unset (single squash commit landed the task).
* 2. Rebase/cherry-pick strategy — the task-attributable subset of
* `rebaseBaseSha..commitSha`, filtered by `Fusion-Task-Id` trailer or
* conventional-commit subject. Falls back to the full range endpoint
* (`commitSha`) when no per-commit attribution is possible (foreign
* commits interleaved with no trailer/subject match — better to revert
* the endpoint than nothing).
* 3. Lineage snapshot fallback — `TaskCommitAssociation` rows keyed by
* `taskLineageId`, used when `mergeDetails` is absent/incomplete (e.g.
* legacy tasks merged before mergeDetails was captured).
*
* FNXC:TaskRevert 2026-07-04-00:00 (workspace limitation):
* Workspace tasks (`mergeDetails.workspaceLandedShas` present) land commits
* across MULTIPLE sub-repo integration branches with no single coherent
* revert target — reverting one sub-repo's commit without reasoning about the
* others could leave the workspace in a half-reverted, inconsistent state.
* This is explicitly out of scope for FN-7523; the caller should route
* workspace tasks to the AI-undo fallback (FN-7524) instead.
*/
export async function resolveTaskRevertCommits(
task: Pick<Task, "id" | "lineageId" | "mergeDetails">,
opts: ResolveTaskRevertCommitsOptions,
): Promise<ResolvedTaskRevertCommits | UnsupportedTaskRevert> {
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
const mergeDetails = task.mergeDetails;
if (mergeDetails?.workspaceLandedShas && Object.keys(mergeDetails.workspaceLandedShas).length > 0) {
return { supported: false, reason: "workspace-task-revert-unsupported" };
}
if (mergeDetails?.commitSha) {
if (!mergeDetails.rebaseBaseSha) {
// Squash strategy: the single recorded commit is the entire landed change.
return { supported: true, shas: [mergeDetails.commitSha], source: "squash" };
}
// Rebase/cherry-pick strategy: filter the range to this task's own commits.
const rangeRef = `${mergeDetails.rebaseBaseSha}..${mergeDetails.commitSha}`;
let logOutput: string;
try {
const { stdout } = await runGit(
execImpl,
`git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(rangeRef)}`,
opts.worktreePath,
);
logOutput = stdout;
} catch (error) {
throw new TaskRevertError(`git log failed for range ${rangeRef}`, "git-log-failed", error);
}
const ownCommitShas: string[] = [];
const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean);
for (const record of records) {
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
if (!sha) continue;
const body = bodyParts.join("\x00");
const trailerAttributedTaskId = extractAttributedTaskId(body);
const attributedTaskId = trailerAttributedTaskId ?? extractTaskIdFromSubject(subject);
if (taskIdsMatch(attributedTaskId, task.id)) {
ownCommitShas.push(sha);
}
}
if (ownCommitShas.length > 0) {
// `git log` without `--reverse` already yields newest-first order.
return { supported: true, shas: ownCommitShas, source: "rebase" };
}
// No per-commit attribution possible (foreign commits interleaved with no
// trailer/subject match) — fall back to reverting the full range endpoint.
return { supported: true, shas: [mergeDetails.commitSha], source: "rebase" };
}
// mergeDetails absent/incomplete — fall back to the lineage-snapshot association table.
const lineageId = task.lineageId ?? task.id;
if (!opts.commitAssociationSource) {
return { supported: true, shas: [], source: "none" };
}
const associations = await opts.commitAssociationSource.getTaskCommitAssociationsByLineageId(lineageId);
if (associations.length === 0) {
return { supported: true, shas: [], source: "none" };
}
// Rows are already ordered `authoredAt DESC, createdAt DESC` (newest first) by the store query.
return { supported: true, shas: associations.map((a) => a.commitSha), source: "lineage" };
}
export type TaskRevertClassification = "already-reverted" | "clean" | "conflicting";
export interface TaskRevertConflict {
file: string;
/** Raw `git status --porcelain` two-letter status code for the conflicted file (e.g. "UU", "AA"). */
status?: string;
}
export interface ClassifyTaskRevertResult {
classification: TaskRevertClassification;
conflicts?: TaskRevertConflict[];
alreadyReverted?: boolean;
}
export interface ClassifyTaskRevertOptions {
worktreePath: string;
/** Attributable commit SHAs, newest first (see `resolveTaskRevertCommits`). */
commits: string[];
execAsyncImpl?: ExecAsyncImpl;
}
async function getUnmergedFiles(
execImpl: ExecAsyncImpl,
worktreePath: string,
): Promise<TaskRevertConflict[]> {
const { stdout } = await runGit(execImpl, "git status --porcelain", worktreePath);
const conflicts: TaskRevertConflict[] = [];
for (const line of stdout.split("\n")) {
if (!line.trim()) continue;
const status = line.slice(0, 2);
// Unmerged states per `git status --porcelain`: UU, AA, DD, AU, UA, UD, DU.
if (/^(UU|AA|DD|AU|UA|UD|DU)$/.test(status)) {
conflicts.push({ file: line.slice(3).trim(), status });
}
}
return conflicts;
}
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* Rollback safety contract (the core invariant of this service): capture
* `preRevertHead` BEFORE any git mutation. If the working tree is dirty at
* entry, refuse immediately without touching anything. Otherwise, run the
* dry-run revert sequence; regardless of outcome (clean, conflicting, or an
* unexpected error), the `finally` block runs `git revert --abort`
* (best-effort) THEN `git reset --hard <preRevertHead>` so the tree/index are
* byte-identical to the pre-call state. This function NEVER commits and NEVER
* throws without first completing that rollback.
*/
export async function classifyTaskRevert(opts: ClassifyTaskRevertOptions): Promise<ClassifyTaskRevertResult> {
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
const { worktreePath, commits } = opts;
if (commits.length === 0) {
return { classification: "already-reverted", alreadyReverted: true };
}
let preRevertHead: string;
try {
const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
preRevertHead = stdout.trim();
} catch (error) {
throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed", error);
}
if (!preRevertHead) {
throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed");
}
const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", worktreePath);
if (statusOut.trim().length > 0) {
throw new TaskRevertError(
"working tree is dirty; refusing to attempt a revert dry-run",
"dirty-working-tree",
);
}
let mutated = false;
let allAlreadyReverted = true;
const conflicts: TaskRevertConflict[] = [];
try {
for (const sha of commits) {
mutated = true;
const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
try {
await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath);
// FNXC:TaskRevert 2026-07-04-00:00: `git revert --no-commit` on an
// already-reverted commit exits 0 with NO staged/working-tree diff
// (no error, no "nothing to commit" text — that message only ever
// appears from a *subsequent* `git commit` attempt). Detect this by
// diffing `git status --porcelain` before/after the call: if nothing
// changed, this sha is a no-op; `--quit` clears the sequencer's
// in-progress marker WITHOUT touching any diff staged by earlier shas
// in this same batch (unlike `--abort`, which would reset everything).
const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
if (statusAfter === statusBefore) {
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
continue;
}
allAlreadyReverted = false;
} catch (error) {
const stderr =
typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string"
? (error as { stderr: string }).stderr
: "";
const stdout =
typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string"
? (error as { stdout: string }).stdout
: "";
const combined = `${stdout}\n${stderr}`;
const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath);
if (unmergedFiles.length > 0) {
conflicts.push(...unmergedFiles);
allAlreadyReverted = false;
break;
}
// "nothing to commit" / empty-revert signal: this commit's change is
// already reflected as reverted at HEAD — treat as a no-op and continue.
if (/nothing to commit|no changes|empty commit/i.test(combined)) {
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
continue;
}
throw new TaskRevertError(`git revert --no-commit failed unexpectedly for ${sha}`, "revert-dry-run-failed", error);
}
}
} finally {
if (mutated) {
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
}
}
if (conflicts.length > 0) {
return { classification: "conflicting", conflicts };
}
if (allAlreadyReverted) {
return { classification: "already-reverted", alreadyReverted: true };
}
return { classification: "clean" };
}
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<Task, "id" | "lineageId" | "column" | "mergeDetails" | "autoMerge" | "userPaused" | "paused">;
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): <short summary>` (the
* original task's id + a short summary derived from the reverted commit's
* subject), with a body carrying `Fusion-Task-Id: FN-xxxx` (the ORIGINAL
* task id, so `extractAttributedTaskId` continues to resolve attribution back
* to the reverted task) and a `Reverts work landed by task FN-xxxx (<sha>).`
* line for human audit. This mirrors the commit-message conventions in
* AGENTS.md (task-id-prefixed subjects, `Fusion-Task-Id` trailer).
*
* FNXC:TaskRevert 2026-07-04-00:00 (guard rails):
* - Only `done`/`archived` tasks may be reverted (checked here AND at the API
* route layer — defense in depth).
* - When `autoMerge` is effectively off for this task, this function refuses
* with a `needsHuman` result instead of force-writing a revert commit onto
* a branch the project has opted out of automated writes to.
* - This function NEVER mutates the source task's store row/column — reverting
* is a forward-only git operation on the base branch, not a lifecycle move.
*/
export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise<TaskRevertResult> {
// FNXC:TaskRevert 2026-07-04-00:00: `baseBranch` is part of the stable
// caller-facing contract (the route resolves it via mergeTargetBranch /
// the integration-branch resolver) but is not read here directly — the
// caller is responsible for ensuring `worktreePath` is checked out at that
// branch's HEAD before invoking this function; kept as a named, documented
// parameter (not silently dropped) for FN-7524/FN-7525 call-site clarity.
const { task, worktreePath, baseBranch: _baseBranch } = opts;
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
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);
}
}