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>
This commit is contained in:
7
.changeset/fn-7548-per-sha-revert-granularity.md
Normal file
7
.changeset/fn-7548-per-sha-revert-granularity.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add per-sha revert commit granularity to the task revert API and service.
|
||||||
|
category: feature
|
||||||
|
dev: `performTaskRevert` and `POST /api/tasks/:id/revert` accept an optional `granularity: "squash" | "per-sha"` (default `"squash"`, unchanged FN-7523 behavior). `"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 in either mode rolls back the whole batch to the pre-call HEAD — no partially-landed per-sha commits. The clean result now reports `revertCommitShas: string[]` (all created commits) alongside the existing `revertCommitSha` (kept for backward compatibility).
|
||||||
@@ -681,7 +681,8 @@ Recovery/backfill guidance:
|
|||||||
- `"git"` — the FN-7523 git-only behavior. The result (including a conflicting/unsupported result) is returned as-is and never creates a follow-up task.
|
- `"git"` — the FN-7523 git-only behavior. The result (including a conflicting/unsupported result) is returned as-is and never creates a follow-up task.
|
||||||
- `"ai"` — skip git entirely and always create the AI-undo fallback task (FN-7524).
|
- `"ai"` — skip git entirely and always create the AI-undo fallback task (FN-7524).
|
||||||
- `"auto"` — try git first. A clean/alreadyReverted/needsHuman result is returned unchanged. A conflicting or unsupported (e.g. workspace-task) result falls back to creating the AI-undo task.
|
- `"auto"` — try git first. A clean/alreadyReverted/needsHuman result is returned unchanged. A conflicting or unsupported (e.g. workspace-task) result falls back to creating the AI-undo task.
|
||||||
- Git-path response contract (unchanged, additive only): `{ 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.
|
- Also accepts an optional `{ granularity?: "squash" | "per-sha" }` field (FN-7548) that selects the git-path commit granularity: `"squash"` (default, unchanged) accumulates all attributable commits into one revert commit; `"per-sha"` creates one attributed revert 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 in either mode rolls back the whole batch — no partially-landed per-sha commits. This field only affects the git path and is ignored when `mode` resolves to `"ai"`.
|
||||||
|
- Git-path response contract (additive only): `{ mode: "git", clean, revertCommitSha?, revertCommitShas?, 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; `revertCommitShas` reports every commit created (all of them for `per-sha`, the single one for `squash`) alongside the existing `revertCommitSha`.
|
||||||
- AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one.
|
- AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one.
|
||||||
- Workspace (multi-repo) tasks return `unsupported` from the git path (routing `auto` to the AI-undo fallback); `autoMerge:false` projects return `needsHuman` and never trigger the AI-undo fallback (a human/future UI decides).
|
- Workspace (multi-repo) tasks return `unsupported` from the git path (routing `auto` to the AI-undo fallback); `autoMerge:false` projects return `needsHuman` and never trigger the AI-undo fallback (a human/future UI decides).
|
||||||
- No dashboard UI affordance ships with this yet (see the Done/Archived card action follow-up task).
|
- No dashboard UI affordance ships with this yet (see the Done/Archived card action follow-up task).
|
||||||
|
|||||||
@@ -104,8 +104,11 @@ function createApp(store: TaskStore) {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function REQUEST(app: express.Express, method: string, path: string) {
|
async function REQUEST(app: express.Express, method: string, path: string, body?: unknown) {
|
||||||
return performRequest(app, method, path);
|
if (body === undefined) {
|
||||||
|
return performRequest(app, method, path);
|
||||||
|
}
|
||||||
|
return performRequest(app, method, path, JSON.stringify(body), { "content-type": "application/json" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function POST_JSON(app: express.Express, path: string, body: Record<string, unknown>) {
|
async function POST_JSON(app: express.Express, path: string, body: Record<string, unknown>) {
|
||||||
@@ -314,4 +317,43 @@ describe("POST /tasks/:id/revert — FN-7524 mode + AI-undo fallback", () => {
|
|||||||
expect(res.body).toMatchObject({ mode: "git", needsHuman: true });
|
expect(res.body).toMatchObject({ mode: "git", needsHuman: true });
|
||||||
expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FN-7548: the optional `granularity` request-body field ("squash" | "per-sha")
|
||||||
|
// is validated at the route and forwarded verbatim to `performTaskRevert`.
|
||||||
|
it("forwards granularity: \"per-sha\" to the engine service and returns the revertCommitShas result shape", async () => {
|
||||||
|
const task = makeTask({ column: "done" });
|
||||||
|
const store = createMockStore(task);
|
||||||
|
performTaskRevertMock.mockResolvedValue({
|
||||||
|
mode: "git",
|
||||||
|
clean: true,
|
||||||
|
revertCommitSha: "def456",
|
||||||
|
revertCommitShas: ["def456", "abc123"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`, { granularity: "per-sha" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ mode: "git", clean: true, revertCommitShas: ["def456", "abc123"] });
|
||||||
|
expect(performTaskRevertMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(performTaskRevertMock.mock.calls[0]?.[0]).toMatchObject({ granularity: "per-sha" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown granularity value with a 400, before invoking the engine service", async () => {
|
||||||
|
const task = makeTask({ column: "done" });
|
||||||
|
const store = createMockStore(task);
|
||||||
|
|
||||||
|
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`, { granularity: "bogus" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(String((res.body as { error?: string }).error ?? "")).toMatch(/granularity/i);
|
||||||
|
expect(performTaskRevertMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to squash granularity when the body omits the field, preserving existing behavior", async () => {
|
||||||
|
const task = makeTask({ column: "done" });
|
||||||
|
const store = createMockStore(task);
|
||||||
|
performTaskRevertMock.mockResolvedValue({ mode: "git", clean: true, revertCommitSha: "abc123", revertCommitShas: ["abc123"] });
|
||||||
|
|
||||||
|
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(performTaskRevertMock.mock.calls[0]?.[0]).toMatchObject({ granularity: "squash" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1670,25 +1670,30 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract):
|
FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract; FN-7548 granularity contract):
|
||||||
POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523), with an
|
POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523), with an
|
||||||
AI-undo fallback (FN-7524, foundation for FN-7501). Guard rails (enforced here AND in the
|
AI-undo fallback (FN-7524, foundation for FN-7501) and per-sha revert-commit granularity
|
||||||
engine service):
|
(FN-7548). Guard rails (enforced here AND in the engine service):
|
||||||
- only done/archived tasks are revertable (400/409 otherwise);
|
- only done/archived tasks are revertable (400/409 otherwise);
|
||||||
- autoMerge-off is a needsHuman result, not a forced write, and NEVER triggers the AI fallback
|
- autoMerge-off is a needsHuman result, not a forced write, and NEVER triggers the AI fallback
|
||||||
(leave that for a human / sibling FN-7525 to decide);
|
(leave that for a human / sibling FN-7525 to decide);
|
||||||
- the source task's column/status is NEVER mutated as a side effect of a revert (git OR AI path).
|
- the source task's column/status is NEVER mutated as a side effect of a revert (git OR AI path).
|
||||||
|
|
||||||
Optional request body: `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`; unknown values reject
|
Optional request body:
|
||||||
with 400). Semantics:
|
- `mode?: "git" | "ai" | "auto"` (default `"auto"`; unknown values reject with 400). Semantics:
|
||||||
- `"git"` — FN-7523 behavior only; the git result (incl. a conflict/unsupported result) is
|
- `"git"` — FN-7523 behavior only; the git result (incl. a conflict/unsupported result) is
|
||||||
returned as-is and the AI-undo path is NEVER invoked.
|
returned as-is and the AI-undo path is NEVER invoked.
|
||||||
- `"ai"` — skip git entirely; always take the AI-undo fallback.
|
- `"ai"` — skip git entirely; always take the AI-undo fallback.
|
||||||
- `"auto"` — attempt git first. A clean/alreadyReverted/needsHuman git result is returned
|
- `"auto"` — attempt git first. A clean/alreadyReverted/needsHuman git result is returned
|
||||||
unchanged (NO AI task created). A conflicting or unsupported (e.g. workspace-task) git result
|
unchanged (NO AI task created). A conflicting or unsupported (e.g. workspace-task) git result
|
||||||
falls through to the AI-undo fallback.
|
falls through to the AI-undo fallback.
|
||||||
|
- `granularity?: "squash" | "per-sha"` (FN-7548, default `"squash"`) — commit granularity for the
|
||||||
|
git-path revert only; forwarded verbatim to `performTaskRevert`. `"squash"` preserves the
|
||||||
|
unchanged FN-7523 single-commit behavior; `"per-sha"` creates one attributed revert commit per
|
||||||
|
original sha (see `performTaskRevert`'s per-sha apply path). Ignored when `mode` resolves to `"ai"`.
|
||||||
|
|
||||||
Response contract is ADDITIVE over FN-7523: `{ mode: "git", ... }` (unchanged shape) OR
|
Response contract is ADDITIVE over FN-7523: `{ mode: "git", clean, revertCommitSha?, revertCommitShas?,
|
||||||
|
conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }` OR
|
||||||
`{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via
|
`{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via
|
||||||
`createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency
|
`createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency
|
||||||
guard — a second call while an undo task is still open returns the SAME `createdTaskId` with
|
guard — a second call while an undo task is still open returns the SAME `createdTaskId` with
|
||||||
@@ -1711,6 +1716,25 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
}
|
}
|
||||||
const mode: "git" | "ai" | "auto" = (requestedMode as "git" | "ai" | "auto" | undefined) ?? "auto";
|
const mode: "git" | "ai" | "auto" = (requestedMode as "git" | "ai" | "auto" | undefined) ?? "auto";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:TaskRevert 2026-07-04-12:00 (FN-7548):
|
||||||
|
Optional `granularity` request-body field selects the commit granularity
|
||||||
|
of the revert: `"squash"` (default, unchanged FN-7523 behavior — one
|
||||||
|
combined revert commit) or `"per-sha"` (one attributed revert commit per
|
||||||
|
original sha, see `performTaskRevert`'s per-sha apply path). An absent/
|
||||||
|
empty value defaults to `"squash"`; any other value is a 400 naming the
|
||||||
|
allowed values. Only relevant to the git path — ignored when `mode`
|
||||||
|
resolves to `"ai"`.
|
||||||
|
*/
|
||||||
|
const requestedGranularity = (req.body as { granularity?: unknown } | undefined)?.granularity;
|
||||||
|
let granularity: "squash" | "per-sha" = "squash";
|
||||||
|
if (requestedGranularity !== undefined && requestedGranularity !== null && requestedGranularity !== "") {
|
||||||
|
if (requestedGranularity !== "squash" && requestedGranularity !== "per-sha") {
|
||||||
|
throw badRequest(`granularity must be one of: "squash", "per-sha"`);
|
||||||
|
}
|
||||||
|
granularity = requestedGranularity;
|
||||||
|
}
|
||||||
|
|
||||||
const createAiUndoResult = async (): Promise<AiUndoTaskResult> =>
|
const createAiUndoResult = async (): Promise<AiUndoTaskResult> =>
|
||||||
createAiUndoTask({
|
createAiUndoTask({
|
||||||
createTask: (input) => scopedStore.createTask(input),
|
createTask: (input) => scopedStore.createTask(input),
|
||||||
@@ -1758,6 +1782,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
scopedStore.getTaskCommitAssociationsByLineageId(lineageId),
|
scopedStore.getTaskCommitAssociationsByLineageId(lineageId),
|
||||||
},
|
},
|
||||||
effectiveAutoMerge: settings.autoMerge,
|
effectiveAutoMerge: settings.autoMerge,
|
||||||
|
granularity,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mode === "git") {
|
if (mode === "git") {
|
||||||
|
|||||||
@@ -245,4 +245,128 @@ describeIfGit("task-revert real-git scenarios", { timeout: 30_000 }, () => {
|
|||||||
expect(result).toMatchObject({ mode: "git", needsHuman: true });
|
expect(result).toMatchObject({ mode: "git", needsHuman: true });
|
||||||
expect(git(repo, "git rev-parse HEAD")).toBe(preHead);
|
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("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ export {
|
|||||||
REVERT_OF_METADATA_KEY,
|
REVERT_OF_METADATA_KEY,
|
||||||
type AiUndoTaskResult,
|
type AiUndoTaskResult,
|
||||||
type CreateAiUndoTaskDeps,
|
type CreateAiUndoTaskDeps,
|
||||||
|
type TaskRevertGranularity,
|
||||||
|
type PerformTaskRevertOptions,
|
||||||
} from "./task-revert.js";
|
} from "./task-revert.js";
|
||||||
export {
|
export {
|
||||||
resolveBranchGroupMergeRouting,
|
resolveBranchGroupMergeRouting,
|
||||||
|
|||||||
@@ -352,13 +352,86 @@ export async function classifyTaskRevert(opts: ClassifyTaskRevertOptions): Promi
|
|||||||
return { classification: "clean" };
|
return { classification: "clean" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FNXC:TaskRevert 2026-07-04-12:00 (shared per-sha apply primitive, FN-7548):
|
||||||
|
// factors the `git revert --no-commit` + status-diff no-op detection +
|
||||||
|
// unmerged-file conflict detection used by BOTH performTaskRevert apply paths
|
||||||
|
// (squash and per-sha) into one place. Returns a discriminated outcome
|
||||||
|
// instead of committing or rolling back itself — callers own the
|
||||||
|
// commit/rollback decision (squash accumulates across shas before
|
||||||
|
// committing once; per-sha commits after each staged sha).
|
||||||
|
type RevertShaApplyOutcome =
|
||||||
|
| { kind: "staged" }
|
||||||
|
| { kind: "noop" }
|
||||||
|
| { kind: "conflict"; conflicts: TaskRevertConflict[] };
|
||||||
|
|
||||||
|
async function applyRevertNoCommit(
|
||||||
|
execImpl: ExecAsyncImpl,
|
||||||
|
worktreePath: string,
|
||||||
|
sha: string,
|
||||||
|
): Promise<RevertShaApplyOutcome> {
|
||||||
|
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
|
||||||
|
// thrown error, no "nothing to commit" text on this call). Detect this by
|
||||||
|
// diffing `git status --porcelain` before/after: if unchanged, 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.
|
||||||
|
const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
|
||||||
|
if (statusAfter === statusBefore) {
|
||||||
|
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
|
||||||
|
return { kind: "noop" };
|
||||||
|
}
|
||||||
|
return { kind: "staged" };
|
||||||
|
} 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);
|
||||||
|
return { kind: "noop" };
|
||||||
|
}
|
||||||
|
const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath);
|
||||||
|
return { kind: "conflict", conflicts: unmergedFiles };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveShortSummary(originalSubject: string): string {
|
||||||
|
return (
|
||||||
|
originalSubject
|
||||||
|
.replace(/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style)\([^)]*\):\s*/i, "")
|
||||||
|
.slice(0, 72) || "revert landed changes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type TaskRevertResult =
|
export type TaskRevertResult =
|
||||||
| { mode: "git"; clean: true; revertCommitSha: string }
|
| { mode: "git"; clean: true; revertCommitSha: string; revertCommitShas: string[] }
|
||||||
| { mode: "git"; clean: true; alreadyReverted: true }
|
| { mode: "git"; clean: true; alreadyReverted: true }
|
||||||
| { mode: "git"; clean: false; conflicts: TaskRevertConflict[] }
|
| { mode: "git"; clean: false; conflicts: TaskRevertConflict[] }
|
||||||
| { mode: "git"; unsupported: true; reason: string }
|
| { mode: "git"; unsupported: true; reason: string }
|
||||||
| { mode: "git"; needsHuman: true; reason: string };
|
| { mode: "git"; needsHuman: true; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:TaskRevert 2026-07-04-12:00 (granularity, FN-7548):
|
||||||
|
* `"squash"` (default, unchanged FN-7523 behavior) accumulates every
|
||||||
|
* attributable sha into ONE final revert commit. `"per-sha"` creates one
|
||||||
|
* attributed `revert(FN-xxxx): ...` commit PER non-no-op original sha, each
|
||||||
|
* with its own `Fusion-Task-Id` trailer and an audit line referencing that
|
||||||
|
* specific sha — giving finer-grained audit trail / rollback (an operator
|
||||||
|
* can drop a single per-sha revert without unwinding the whole task). A
|
||||||
|
* mid-batch conflict in EITHER mode rolls the whole batch back to
|
||||||
|
* `preRevertHead` — partially-landed per-commit reverts are never left on
|
||||||
|
* disk (see the shared `mutated`/`preRevertHead` rollback in the outer
|
||||||
|
* catch, and the inline abort+reset on conflict below).
|
||||||
|
*/
|
||||||
|
export type TaskRevertGranularity = "squash" | "per-sha";
|
||||||
|
|
||||||
export interface PerformTaskRevertOptions {
|
export interface PerformTaskRevertOptions {
|
||||||
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "autoMerge" | "userPaused" | "paused">;
|
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "autoMerge" | "userPaused" | "paused">;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
@@ -367,6 +440,8 @@ export interface PerformTaskRevertOptions {
|
|||||||
commitAssociationSource?: TaskCommitAssociationSource;
|
commitAssociationSource?: TaskCommitAssociationSource;
|
||||||
/** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */
|
/** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */
|
||||||
effectiveAutoMerge?: boolean;
|
effectiveAutoMerge?: boolean;
|
||||||
|
/** Commit granularity for the real (committing) revert. Defaults to `"squash"` — omitting this option preserves FN-7523 behavior exactly. */
|
||||||
|
granularity?: TaskRevertGranularity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FNXC:TaskRevert 2026-07-04-00:00 (guard rails, enforced in BOTH the service
|
// FNXC:TaskRevert 2026-07-04-00:00 (guard rails, enforced in BOTH the service
|
||||||
@@ -442,46 +517,78 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise
|
|||||||
throw new TaskRevertError("failed to resolve HEAD before applying revert", "head-resolve-failed", error);
|
throw new TaskRevertError("failed to resolve HEAD before applying revert", "head-resolve-failed", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const granularity: TaskRevertGranularity = opts.granularity ?? "squash";
|
||||||
let mutated = false;
|
let mutated = false;
|
||||||
let anyStaged = false;
|
|
||||||
try {
|
try {
|
||||||
|
if (granularity === "per-sha") {
|
||||||
|
// FNXC:TaskRevert 2026-07-04-12:00 (per-commit apply path, FN-7548):
|
||||||
|
// stage-and-commit ONE sha at a time so each attributable original sha
|
||||||
|
// gets its own attributed revert commit. No-op shas (already reverted
|
||||||
|
// at HEAD) are skipped without creating an empty commit. A conflict on
|
||||||
|
// any sha rolls the ENTIRE batch back to preRevertHead — there is no
|
||||||
|
// partially-landed per-commit state.
|
||||||
|
const createdCommitShas: string[] = [];
|
||||||
|
for (const sha of resolved.shas) {
|
||||||
|
mutated = true;
|
||||||
|
const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha);
|
||||||
|
if (outcome.kind === "conflict") {
|
||||||
|
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: outcome.conflicts };
|
||||||
|
}
|
||||||
|
if (outcome.kind === "noop") continue;
|
||||||
|
|
||||||
|
let originalSubject = "";
|
||||||
|
try {
|
||||||
|
const { stdout } = await runGit(execImpl, `git log -1 --format=%s ${quoteShellArg(sha)}`, worktreePath);
|
||||||
|
originalSubject = stdout.trim();
|
||||||
|
} catch {
|
||||||
|
originalSubject = "";
|
||||||
|
}
|
||||||
|
const shortSummary = deriveShortSummary(originalSubject);
|
||||||
|
const subject = `revert(${task.id}): ${shortSummary}`;
|
||||||
|
const body1 = `Fusion-Task-Id: ${task.id}`;
|
||||||
|
const body2 = `Reverts ${originalSubject || sha} @ ${sha.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);
|
||||||
|
createdCommitShas.push(newHead.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdCommitShas.length === 0) {
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: "git",
|
||||||
|
clean: true,
|
||||||
|
revertCommitSha: createdCommitShas[0]!,
|
||||||
|
revertCommitShas: createdCommitShas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// granularity === "squash" (default, byte-for-byte unchanged FN-7523 behavior):
|
||||||
|
// accumulate every attributable sha via `git revert --no-commit`, then create
|
||||||
|
// ONE final commit spanning the whole batch.
|
||||||
|
let anyStaged = false;
|
||||||
for (const sha of resolved.shas) {
|
for (const sha of resolved.shas) {
|
||||||
mutated = true;
|
mutated = true;
|
||||||
const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
|
const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha);
|
||||||
try {
|
if (outcome.kind === "conflict") {
|
||||||
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 dry-run already proved this is clean; a live conflict here means
|
||||||
// the branch moved between classify and apply. Roll back and report conflicting.
|
// 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 revert --abort", worktreePath).catch(() => undefined);
|
||||||
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
|
||||||
return { mode: "git", clean: false, conflicts: unmergedFiles };
|
return { mode: "git", clean: false, conflicts: outcome.conflicts };
|
||||||
}
|
}
|
||||||
|
if (outcome.kind === "staged") anyStaged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!anyStaged) {
|
if (!anyStaged) {
|
||||||
@@ -500,7 +607,7 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise
|
|||||||
originalSubject = "";
|
originalSubject = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortSummary = originalSubject.replace(/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style)\([^)]*\):\s*/i, "").slice(0, 72) || "revert landed changes";
|
const shortSummary = deriveShortSummary(originalSubject);
|
||||||
const subject = `revert(${task.id}): ${shortSummary}`;
|
const subject = `revert(${task.id}): ${shortSummary}`;
|
||||||
const referencedSha = resolved.shas[0] ?? "unknown";
|
const referencedSha = resolved.shas[0] ?? "unknown";
|
||||||
const body1 = `Fusion-Task-Id: ${task.id}`;
|
const body1 = `Fusion-Task-Id: ${task.id}`;
|
||||||
@@ -513,7 +620,8 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
|
||||||
return { mode: "git", clean: true, revertCommitSha: newHead.trim() };
|
const revertCommitSha = newHead.trim();
|
||||||
|
return { mode: "git", clean: true, revertCommitSha, revertCommitShas: [revertCommitSha] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mutated) {
|
if (mutated) {
|
||||||
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user