Files
fusion/packages/engine/src/__tests__/task-revert.real-git.test.ts
gsxdsm 8c6f76c37e FN-7548: add per-sha revert commit granularity to the git-revert service/route
Adds an opt-in per-sha commit granularity mode to the task-revert git path, alongside its default squash behavior, and merges it cleanly with the existing FN-7524 AI-undo mode support.

- Add `TaskRevertGranularity` ("squash" | "per-sha") and thread an optional `granularity` option through `performTaskRevert`/`PerformTaskRevertOptions`.
- Factor a shared `applyRevertNoCommit` primitive (stage + no-op/conflict detection) used by both the squash and new per-sha apply paths.
- `"per-sha"` creates one attributed `revert(FN-xxxx): ...` commit per original sha (each with its own `Fusion-Task-Id` trailer and audit line), skipping no-op shas without empty commits; a mid-batch conflict rolls the whole batch back to the pre-call HEAD.
- Extend `TaskRevertResult`'s clean shape with `revertCommitShas: string[]` (all created commits) alongside the existing `revertCommitSha`.
- `POST /api/tasks/:id/revert` accepts an optional `granularity` request-body field (default `"squash"`, validated, 400 on unknown values) and forwards it to the engine service; documented alongside the existing `mode` (git/ai/auto) contract.
- Add real-git and route-level test coverage for per-sha creation, no-op skipping, default-squash behavior, and mid-batch conflict rollback.
- Update docs/task-management.md's revert section and add a changeset.

Files changed:
 .changeset/fn-7548-per-sha-revert-granularity.md   |   7 +
 docs/task-management.md                            |   3 +-
 packages/dashboard/src/__tests__/task-revert-route.test.ts        |  46 +++++-
 packages/dashboard/src/routes/register-task-workflow-routes.ts    |  51 ++++--
 packages/engine/src/__tests__/task-revert.real-git.test.ts     | 124 +++++++++++++++
 packages/engine/src/index.ts                       |   2 +
 packages/engine/src/task-revert.ts                 | 176 +++++++++++++++++----
 7 files changed, 359 insertions(+), 50 deletions(-)

Fusion-Task-Id: FN-7548

Fusion-Task-Lineage: b9548f5e-fcc2-45d4-98e0-dd7340928208

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-05 11:31:44 -07:00

373 lines
17 KiB
TypeScript

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);
});
// FN-7548: per-sha revert commit granularity — one attributed revert commit
// per original sha instead of a single squashed commit, with the default
// ("squash") staying byte-for-byte unchanged.
function twoCommitRebaseFixture() {
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' -m 'Fusion-Task-Id: FN-901'");
const shaA = git(repo, "git rev-parse HEAD");
writeFileSync(join(repo, "bar.ts"), "bar-feature\n");
git(repo, "git add bar.ts && git commit -m 'feat(FN-901): part 2' -m 'Fusion-Task-Id: FN-901'");
const shaB = git(repo, "git rev-parse HEAD");
return { repo, rebaseBase, shaA, shaB };
}
it("per-sha granularity: creates one attributed revert commit per original sha", async () => {
const { repo, rebaseBase, shaA, shaB } = twoCommitRebaseFixture();
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" });
expect(result).toMatchObject({ mode: "git", clean: true });
if (result.mode === "git" && result.clean && "revertCommitShas" in result) {
expect(result.revertCommitShas.length).toBe(2);
expect(result.revertCommitSha).toBe(result.revertCommitShas[0]);
}
const subjects = git(repo, "git log --format=%s -n 5").split("\n");
const revertSubjects = subjects.filter((s) => s.startsWith("revert(FN-901):"));
expect(revertSubjects.length).toBe(2);
// Two distinct new commits, both carrying the Fusion-Task-Id trailer and
// each referencing a DIFFERENT original sha in its audit line.
const bodyHead = git(repo, "git log -1 --format=%B HEAD");
const bodyHeadMinus1 = git(repo, "git log -1 --format=%B HEAD~1");
expect(bodyHead).toContain("Fusion-Task-Id: FN-901");
expect(bodyHeadMinus1).toContain("Fusion-Task-Id: FN-901");
expect(bodyHead).toContain(shaA.slice(0, 8));
expect(bodyHeadMinus1).toContain(shaB.slice(0, 8));
expect(git(repo, "git show HEAD:foo.ts")).toBe("line1");
expect(() => git(repo, "git show HEAD:bar.ts")).toThrow();
expect(git(repo, "git status --porcelain")).toBe("");
});
it("default stays squashed: the same two-commit task without granularity produces exactly one revert commit", async () => {
const { repo, rebaseBase, shaB } = twoCommitRebaseFixture();
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" });
expect(result).toMatchObject({ mode: "git", clean: true });
if (result.mode === "git" && result.clean && "revertCommitShas" in result) {
expect(result.revertCommitShas.length).toBe(1);
expect(result.revertCommitSha).toBe(result.revertCommitShas[0]);
}
const subjects = git(repo, "git log --format=%s -n 5").split("\n");
const revertSubjects = subjects.filter((s) => s.startsWith("revert(FN-901):"));
expect(revertSubjects.length).toBe(1);
expect(git(repo, "git show HEAD:foo.ts")).toBe("line1");
expect(() => git(repo, "git show HEAD:bar.ts")).toThrow();
});
it("per-sha granularity: no-op shas are skipped without creating empty commits", async () => {
const { repo, rebaseBase, shaB } = twoCommitRebaseFixture();
// Pre-revert shaB manually so it is already reverted at HEAD before the real call.
git(repo, `git revert --no-edit ${shaB}`);
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" });
expect(result).toMatchObject({ mode: "git", clean: true });
if (result.mode === "git" && result.clean && "revertCommitShas" in result) {
expect(result.revertCommitShas.length).toBe(1);
}
// foo.ts (shaA's change) should now be reverted; bar.ts was already gone from the manual revert.
expect(git(repo, "git show HEAD:foo.ts")).toBe("line1");
expect(() => git(repo, "git show HEAD:bar.ts")).toThrow();
expect(git(repo, "git status --porcelain")).toBe("");
});
it("per-sha granularity: a conflicting batch rolls back entirely — no partially-landed per-sha commits", async () => {
const { repo, rebaseBase, shaB } = twoCommitRebaseFixture();
// Task C later modifies the same region touched by shaA (foo.ts), so reverting shaA conflicts.
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a-modified-by-c\n");
git(repo, "git commit -am 'feat(FN-903): modify same region as part 1'");
const preCallHead = git(repo, "git rev-parse HEAD");
const preCallStatus = git(repo, "git status --porcelain");
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" });
expect(result).toMatchObject({ mode: "git", clean: false });
if (result.mode === "git" && !result.clean && "conflicts" in result) {
expect(result.conflicts.length).toBeGreaterThan(0);
}
// No partial per-sha commits landed — tree/HEAD byte-identical to the pre-call state,
// proving the whole batch (including any earlier per-sha commit) is rolled back.
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("");
});
});