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:
7
.changeset/fn-7523-task-revert.md
Normal file
7
.changeset/fn-7523-task-revert.md
Normal 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`.
|
||||
@@ -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`.
|
||||
|
||||
180
packages/dashboard/src/__tests__/task-revert-route.test.ts
Normal file
180
packages/dashboard/src/__tests__/task-revert-route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
248
packages/engine/src/__tests__/task-revert.real-git.test.ts
Normal file
248
packages/engine/src/__tests__/task-revert.real-git.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
524
packages/engine/src/task-revert.ts
Normal file
524
packages/engine/src/task-revert.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user