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>
373 lines
17 KiB
TypeScript
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("");
|
|
});
|
|
});
|