From 8c6f76c37e685985c04ee9035e4d27777a820e90 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 21:54:20 -0700 Subject: [PATCH] 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) --- .../fn-7548-per-sha-revert-granularity.md | 7 + docs/task-management.md | 3 +- .../src/__tests__/task-revert-route.test.ts | 46 ++++- .../routes/register-task-workflow-routes.ts | 49 +++-- .../__tests__/task-revert.real-git.test.ts | 124 ++++++++++++ packages/engine/src/index.ts | 2 + packages/engine/src/task-revert.ts | 176 ++++++++++++++---- 7 files changed, 358 insertions(+), 49 deletions(-) create mode 100644 .changeset/fn-7548-per-sha-revert-granularity.md diff --git a/.changeset/fn-7548-per-sha-revert-granularity.md b/.changeset/fn-7548-per-sha-revert-granularity.md new file mode 100644 index 0000000000..657bd6db0f --- /dev/null +++ b/.changeset/fn-7548-per-sha-revert-granularity.md @@ -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). diff --git a/docs/task-management.md b/docs/task-management.md index fbe810479a..075b23c936 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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. - `"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. -- 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. - 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). diff --git a/packages/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index 7c90e1a470..134e87f239 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -104,8 +104,11 @@ function createApp(store: TaskStore) { return app; } -async function REQUEST(app: express.Express, method: string, path: string) { - return performRequest(app, method, path); +async function REQUEST(app: express.Express, method: string, path: string, body?: unknown) { + 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) { @@ -314,4 +317,43 @@ describe("POST /tasks/:id/revert — FN-7524 mode + AI-undo fallback", () => { expect(res.body).toMatchObject({ mode: "git", needsHuman: true }); expect(store.createTask as ReturnType).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" }); + }); }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index feeae9fc52..339ba40530 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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 - AI-undo fallback (FN-7524, foundation for FN-7501). Guard rails (enforced here AND in the - engine service): + AI-undo fallback (FN-7524, foundation for FN-7501) and per-sha revert-commit granularity + (FN-7548). 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, and NEVER triggers the AI fallback (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). - Optional request body: `{ 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 - returned as-is and the AI-undo path is NEVER invoked. - - `"ai"` — skip git entirely; always take the AI-undo fallback. - - `"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 - falls through to the AI-undo fallback. + Optional request body: + - `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 + returned as-is and the AI-undo path is NEVER invoked. + - `"ai"` — skip git entirely; always take the AI-undo fallback. + - `"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 + 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 `createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency 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"; + /* + 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 => createAiUndoTask({ createTask: (input) => scopedStore.createTask(input), @@ -1758,6 +1782,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork scopedStore.getTaskCommitAssociationsByLineageId(lineageId), }, effectiveAutoMerge: settings.autoMerge, + granularity, }); if (mode === "git") { diff --git a/packages/engine/src/__tests__/task-revert.real-git.test.ts b/packages/engine/src/__tests__/task-revert.real-git.test.ts index 99c96b9fb2..b1f563b5a9 100644 --- a/packages/engine/src/__tests__/task-revert.real-git.test.ts +++ b/packages/engine/src/__tests__/task-revert.real-git.test.ts @@ -245,4 +245,128 @@ describeIfGit("task-revert real-git scenarios", { timeout: 30_000 }, () => { 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(""); + }); }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9f95db5771..520c17d92c 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -284,6 +284,8 @@ export { REVERT_OF_METADATA_KEY, type AiUndoTaskResult, type CreateAiUndoTaskDeps, + type TaskRevertGranularity, + type PerformTaskRevertOptions, } from "./task-revert.js"; export { resolveBranchGroupMergeRouting, diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts index 1a36e101ce..8481794217 100644 --- a/packages/engine/src/task-revert.ts +++ b/packages/engine/src/task-revert.ts @@ -352,13 +352,86 @@ export async function classifyTaskRevert(opts: ClassifyTaskRevertOptions): Promi 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 { + 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 = - | { mode: "git"; clean: true; revertCommitSha: string } + | { mode: "git"; clean: true; revertCommitSha: string; revertCommitShas: 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 }; +/** + * 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 { task: Pick; worktreePath: string; @@ -367,6 +440,8 @@ export interface PerformTaskRevertOptions { commitAssociationSource?: TaskCommitAssociationSource; /** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */ 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 @@ -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); } + const granularity: TaskRevertGranularity = opts.granularity ?? "squash"; let mutated = false; - let anyStaged = false; 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) { 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; - } + const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha); + if (outcome.kind === "conflict") { // 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 }; + return { mode: "git", clean: false, conflicts: outcome.conflicts }; } + if (outcome.kind === "staged") anyStaged = true; } if (!anyStaged) { @@ -500,7 +607,7 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise 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 referencedSha = resolved.shas[0] ?? "unknown"; 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); - return { mode: "git", clean: true, revertCommitSha: newHead.trim() }; + const revertCommitSha = newHead.trim(); + return { mode: "git", clean: true, revertCommitSha, revertCommitShas: [revertCommitSha] }; } catch (error) { if (mutated) { await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);