fix(FN-5345): refuse empty commits + early empty-own-diff finalize
Three engine-level fixes for the in-review wedge class identified via FN-5345
(verification-only task with empty handoff commit + drifted worktree mapping
escalating to 'merge-deadlock-detected: verified content not on main' after
FN-4999 completion-handoff-limbo recovery exhausts).
D1 — prepare-commit-msg empty-commit guard
Fusion task worktrees install a prepare-commit-msg hook that refuses
'git commit --allow-empty' and other zero-staged-diff commits. Amend
(detected via $2==commit source arg or '--amend' in 'ps -o args= -p $PPID')
and merge/squash/cherry-pick/revert/rebase ceremonies are allowed.
D5 — early empty-own-diff fast-path in aiMergeTask
In reuse-task-worktree integration mode, BEFORE any reuse-handoff acquisition,
detect branches with own_commit_count >= 1 but zero net diff vs merge-base
(git diff --quiet <mergeBase>..<branch>) and finalize as no-op with
mergeDetails.noOpMerge=true + task:auto-recover-finalize-already-on-main
audit (reason: empty-own-diff-early-fast-path). cwd-main integration mode
is unchanged. classifyOwnedLandedEvidence also detects empty-own-diff so
self-healing and post-handoff finalize paths benefit too.
D3 — reuse-fallback consults existing branch registration
Before creating a fresh worktree in merger's reacquireReuseIntegrationWorktree,
consult 'git worktree list --porcelain' for existing registrations of
fusion/<id>. Reuse extant usable registrations directly. Prune stale
registrations first. Eliminates FN-5083-class double-registration where
'git worktree add -f' on an already-registered branch produced two worktrees
both claiming the branch and wedged the next handoff gate.
Tests
- New real-git backstop: prepare-commit-msg-empty-guard.real-git.test.ts
- New reliability interaction backstop in merge-reuse-task-worktree.test.ts
('FN-5345: empty-own-diff branch auto-finalizes via early fast-path')
- commit-msg-trailer.real-git.test.ts updated to use real commits (it was
relying on --allow-empty, which the new guard now refuses)
- Full @fusion/engine suite: 448 files / 5879 tests / 9 skipped, all green
- pnpm lint green, pnpm build green
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Engine reliability: prevent the FN-5345 in-review wedge class.
|
||||
|
||||
- Fusion task worktrees now install a `prepare-commit-msg` empty-commit guard that refuses `git commit --allow-empty` and other zero-staged-diff commits, while still allowing legitimate amend / merge / squash / cherry-pick / revert / rebase paths.
|
||||
- Merger gains an early empty-own-diff fast-path in `reuse-task-worktree` integration mode: branches with own commits but zero net tree change vs merge-base now auto-finalize as no-op BEFORE any reuse-handoff acquisition runs, preventing `registered-branch-mismatch` + `merge-deadlock-detected: verified content not on main` wedges.
|
||||
- `classifyOwnedLandedEvidence` also detects the empty-own-diff case and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too.
|
||||
- Merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree, reusing extant usable registrations of `fusion/<id>` and pruning stale ones, eliminating FN-5083-class branch-registration double-registration.
|
||||
@@ -217,6 +217,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
||||
- **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session.
|
||||
- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight.
|
||||
- **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers and emits `scheduler:overlap-priority-inversion` once per (candidate, blocker, pass).
|
||||
- **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The `cwd-main` integration mode is unchanged. `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/<id>` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first.
|
||||
- **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/<id>` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`.
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-main`.
|
||||
@@ -271,7 +272,7 @@ Sanctioned user-facing daemons that intentionally outlive the caller may keep `d
|
||||
- Commit messages: `feat(FN-XXX):`, `fix(FN-XXX):`, `test(FN-XXX):`
|
||||
- One commit per step (not per file change)
|
||||
- Always include the task ID prefix
|
||||
- Fusion-managed task worktrees install both identity-guard `pre-commit` and trailer-appending `commit-msg` hooks; task-worktree commits should carry a `Fusion-Task-Id: FN-NNNN` trailer (FN-5089, configurable via `commitMsgHookEnabled`). Attribution still falls back to branch/subject when the trailer hook is disabled.
|
||||
- Fusion-managed task worktrees install identity-guard `pre-commit`, trailer-appending `commit-msg`, and empty-commit-refusal `prepare-commit-msg` hooks. Task-worktree commits should carry a `Fusion-Task-Id: FN-NNNN` trailer (FN-5089, configurable via `commitMsgHookEnabled`). Attribution still falls back to branch/subject when the trailer hook is disabled. The `prepare-commit-msg` empty-commit guard (FN-5345/FN-5377) refuses `git commit --allow-empty` and other zero-staged-diff commits in fusion worktrees; legitimate amend / merge / squash / cherry-pick / revert / rebase ceremonies are allowed (amend is detected via `$2=="commit"` source arg or `--amend` in `ps -o args= -p $PPID`).
|
||||
|
||||
## Merging Branches Into Main
|
||||
|
||||
@@ -549,6 +550,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
||||
- FN-5039 backstop: `packages/engine/src/__tests__/reliability-interactions/worktree-contamination-attribution.real-git.test.ts` guards `captureModifiedFiles` trailer attribution filtering and `task:worktree-contamination-detected` audit fan-out across rebase contamination, clean, untrailered, and fallback paths.
|
||||
- FN-4976 backstop: `packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts` guards `cleanupConflictingWorktree` clearing stale same-task `activeSessionRegistry` entries before the FN-4811 foreign-owner check, while preserving refusal behavior for foreign owners and live same-task bindings.
|
||||
- FN-4999 backstop: `packages/engine/src/__tests__/reliability-interactions/completion-handoff-limbo.test.ts` covers the `recoverCompletionHandoffLimbo` sweep stage (grace window, active-task skip, merge-blocker guard, capped retries, and audit fan-out).
|
||||
- FN-5345/FN-5377 backstops: `packages/engine/src/__tests__/reliability-interactions/merge-reuse-task-worktree.test.ts` (`FN-5345: empty-own-diff branch auto-finalizes via early fast-path without acquiring reuse handoff`) covers the early no-op fast-path under drifted worktree mapping; `packages/engine/src/__tests__/real-git/prepare-commit-msg-empty-guard.real-git.test.ts` covers the empty-commit refusal hook (refuses `--allow-empty`, allows amend + real commits, no-op outside fusion worktrees).
|
||||
- FN-5083 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-branch-rebind.test.ts` covers in-review branch rebind composition with metadata-cleared state, idempotent re-sweeps, and ambiguous-candidate skip behavior.
|
||||
- FN-5093 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-stalled-detector.test.ts` covers composition between quiet-window in-review stalled surfacing and adjacent reason-driven/paused/ghost-recovery/auto-merge gating paths.
|
||||
- FN-5103 backstop: `packages/engine/src/__tests__/reliability-interactions/landed-files-attribution.test.ts` covers attribution-restricted rebase landed-files capture, verified-short-circuit zero-own-commit capture, and attribution-failure fallback composition.
|
||||
|
||||
@@ -29,24 +29,30 @@ describe("commit-msg trailer hook (real git)", () => {
|
||||
taskAttributionTrailerName: "Fusion-Task-Id",
|
||||
});
|
||||
|
||||
git(worktreeDir, "git commit --allow-empty -m 'feat(KB-7): first'");
|
||||
// FN-5345/FN-5377: pre-commit hook now refuses empty commits in fusion
|
||||
// worktrees, so this test stages a real file change for each commit
|
||||
// instead of using --allow-empty. The trailer-hook behavior under test
|
||||
// is unchanged.
|
||||
writeFileSync(join(worktreeDir, "step1.txt"), "first\n");
|
||||
git(worktreeDir, "git add step1.txt && git commit -m 'feat(KB-7): first'");
|
||||
const firstBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect(firstBody).toContain("Fusion-Task-Id: KB-7");
|
||||
const firstTrailers = git(worktreeDir, "git log -1 --format=%B | git interpret-trailers --parse");
|
||||
expect(firstTrailers).toContain("Fusion-Task-Id: KB-7");
|
||||
|
||||
git(worktreeDir, "git commit --amend --no-edit --allow-empty");
|
||||
git(worktreeDir, "git commit --amend --no-edit");
|
||||
const amendNoEditBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect((amendNoEditBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
|
||||
|
||||
git(worktreeDir, "git commit --amend -m 'feat(KB-7): rewritten' --allow-empty");
|
||||
git(worktreeDir, "git commit --amend -m 'feat(KB-7): rewritten'");
|
||||
const rewrittenBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect(rewrittenBody).toContain("feat(KB-7): rewritten");
|
||||
expect((rewrittenBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
|
||||
|
||||
const taskFile = git(worktreeDir, "git rev-parse --git-path fusion-task-id");
|
||||
writeFileSync(isAbsolute(taskFile) ? taskFile : resolve(worktreeDir, taskFile), "kb-7\n");
|
||||
git(worktreeDir, "git commit --allow-empty -m 'feat(KB-7): lowercase metadata' --allow-empty");
|
||||
writeFileSync(join(worktreeDir, "step2.txt"), "lowercase\n");
|
||||
git(worktreeDir, "git add step2.txt && git commit -m 'feat(KB-7): lowercase metadata'");
|
||||
const lowercaseBody = git(worktreeDir, "git log -1 --format=%B");
|
||||
expect(lowercaseBody).toContain("Fusion-Task-Id: KB-7");
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { installTaskWorktreeIdentityGuard } from "../../worktree-hooks.js";
|
||||
|
||||
function git(dir: string, cmd: string): { stdout: string; stderr: string; status: number | null } {
|
||||
try {
|
||||
const stdout = execSync(cmd, { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }).toString();
|
||||
return { stdout, stderr: "", status: 0 };
|
||||
} catch (err: any) {
|
||||
return { stdout: err.stdout?.toString() ?? "", stderr: err.stderr?.toString() ?? "", status: err.status ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
describe("prepare-commit-msg empty-commit guard (real git, FN-5345/FN-5377)", () => {
|
||||
it("refuses --allow-empty in fusion task worktrees, but allows amend and real commits", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-5345-empty-guard-"));
|
||||
const worktreeDir = join(rootDir, "wt");
|
||||
|
||||
try {
|
||||
git(rootDir, "git init -b main");
|
||||
git(rootDir, 'git config user.email "t@t"');
|
||||
git(rootDir, 'git config user.name "t"');
|
||||
writeFileSync(join(rootDir, "README.md"), "init\n");
|
||||
git(rootDir, "git add README.md");
|
||||
git(rootDir, "git commit -m 'init'");
|
||||
|
||||
git(rootDir, "git worktree add -b fusion/fn-5345 wt HEAD");
|
||||
await installTaskWorktreeIdentityGuard({
|
||||
worktreePath: worktreeDir,
|
||||
taskId: "FN-5345",
|
||||
});
|
||||
|
||||
// Real commit succeeds.
|
||||
writeFileSync(join(worktreeDir, "real.txt"), "real\n");
|
||||
git(worktreeDir, "git add real.txt");
|
||||
const real = git(worktreeDir, "git commit -m 'feat(FN-5345): real'");
|
||||
expect(real.status).toBe(0);
|
||||
|
||||
// --allow-empty with new message is REFUSED.
|
||||
const empty = git(worktreeDir, "git commit --allow-empty -m 'feat(FN-5345): empty handoff'");
|
||||
expect(empty.status).not.toBe(0);
|
||||
expect(empty.stderr).toContain("refusing empty commit");
|
||||
expect(empty.stderr).toContain("FN-5345/FN-5377");
|
||||
|
||||
// --amend --no-edit (no staged changes, amend HEAD) is ALLOWED.
|
||||
const amendNoEdit = git(worktreeDir, "git commit --amend --no-edit");
|
||||
expect(amendNoEdit.status).toBe(0);
|
||||
|
||||
// --amend -m "..." (no staged changes, reword) is ALLOWED.
|
||||
const amendReword = git(worktreeDir, "git commit --amend -m 'feat(FN-5345): reworded'");
|
||||
expect(amendReword.status).toBe(0);
|
||||
|
||||
// Outside fusion worktrees (no fusion-task-id metadata), --allow-empty works normally.
|
||||
const outside = git(rootDir, "git commit --allow-empty -m 'chore: legit empty in root'");
|
||||
expect(outside.status).toBe(0);
|
||||
} finally {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -505,4 +505,85 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
// FN-5345/FN-5377 regression backstop.
|
||||
//
|
||||
// A verification-only task that committed `--allow-empty` produced a branch
|
||||
// with own-commit-count >= 1 but zero net tree change vs merge-base. Combined
|
||||
// with drifted worktree<->branch mapping, the reuse-handoff gate would refuse
|
||||
// with `registered-branch-mismatch` and the task would escalate to
|
||||
// `merge-deadlock-detected: verified content not on main` after FN-4999
|
||||
// completion-handoff-limbo recovery exhausts. The early empty-own-diff
|
||||
// fast-path must finalize this BEFORE any reuse-handoff acquisition runs.
|
||||
it.skipIf(!hasGit)(
|
||||
"FN-5345: empty-own-diff branch auto-finalizes via early fast-path without acquiring reuse handoff",
|
||||
async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5279-RI-EMPTY-OWN-DIFF",
|
||||
settings: {
|
||||
baseBranch: "master",
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
} as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
|
||||
|
||||
git(rootDir, "git branch -m main master");
|
||||
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
|
||||
await store.updateTask(task.id, {
|
||||
baseBranch: "master",
|
||||
branch,
|
||||
steps: completedSteps,
|
||||
currentStep: completedSteps.length,
|
||||
} as any);
|
||||
await fixture.createBranch(branch);
|
||||
// Produce an empty handoff commit on the branch: own_commit_count == 1
|
||||
// but `git diff --quiet mergeBase..branch` exits 0 (no net change).
|
||||
git(rootDir, `git commit --allow-empty -m 'test(${actualTask!.id}): verification-only handoff'`);
|
||||
await fixture.checkout("master");
|
||||
|
||||
// Set up the worktree mapping in a way that would normally wedge the
|
||||
// reuse-handoff gate (FN-5345 scenario): worktree mapped to a
|
||||
// not-yet-created path so classifyTaskWorktree would report `missing`,
|
||||
// forcing reacquire-fallback into FN-5083-class branch-registration drift.
|
||||
await store.updateTask(task.id, {
|
||||
worktree: join(worktreeRoot, "drifted-missing-path"),
|
||||
branch,
|
||||
} as any);
|
||||
store.enqueueMergeQueue(task.id);
|
||||
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(result.mergeConfirmed).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
|
||||
const audits = store.getRunAuditEvents({ taskId: task.id });
|
||||
const auditTypes = audits.map((event) => event.mutationType);
|
||||
|
||||
// Early fast-path must short-circuit BEFORE any reuse-handoff event.
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-refused");
|
||||
expect(auditTypes).not.toContain("merge:reuse-fallback-new-worktree");
|
||||
|
||||
// Records the auto-finalize audit with the empty-own-diff reason.
|
||||
const finalize = audits.find(
|
||||
(event) =>
|
||||
event.mutationType === "task:auto-recover-finalize-already-on-main"
|
||||
&& (event.metadata as any)?.reason === "empty-own-diff-early-fast-path",
|
||||
);
|
||||
expect(finalize).toBeDefined();
|
||||
expect((finalize?.metadata as any)?.aheadCount).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -577,6 +577,63 @@ export async function classifyOwnedLandedEvidence(
|
||||
return { kind: "proven-no-op", baseRef: mergeTargetBranch, ownDiffEmpty: true };
|
||||
}
|
||||
|
||||
// FN-5345/FN-5377: empty-own-diff detection.
|
||||
//
|
||||
// A branch with one or more own commits whose net tree change vs its own
|
||||
// merge-base with the integration branch is empty (e.g. a verification-only
|
||||
// task that produced a `git commit --allow-empty` handoff commit) is
|
||||
// logically equivalent to `proven-no-op` — there is nothing to land.
|
||||
//
|
||||
// We require the merge-base to be reachable from the integration target so
|
||||
// we never claim no-op for a branch rooted off some other ref. This pairs
|
||||
// with the FN-5345/FN-5377 pre-commit empty-commit refusal hook (which
|
||||
// prevents the bad state from being created going forward) and recovers
|
||||
// any tasks already wedged in this state.
|
||||
if (aheadCount !== null && aheadCount > 0) {
|
||||
try {
|
||||
const { stdout: mergeBaseOut } = await execFileAsync(
|
||||
"git",
|
||||
["merge-base", mergeTargetBranch, branch],
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const mergeBase = mergeBaseOut.trim();
|
||||
if (mergeBase) {
|
||||
let ownDiffEmpty = false;
|
||||
try {
|
||||
await execFileAsync(
|
||||
"git",
|
||||
["diff", "--quiet", `${mergeBase}..${branch}`],
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
ownDiffEmpty = true;
|
||||
} catch {
|
||||
// exit non-zero — net diff exists, NOT empty-own-diff
|
||||
ownDiffEmpty = false;
|
||||
}
|
||||
if (ownDiffEmpty) {
|
||||
let mergeBaseReachable = baseReachableFromTarget;
|
||||
if (!mergeBaseReachable) {
|
||||
try {
|
||||
await execFileAsync(
|
||||
"git",
|
||||
["merge-base", "--is-ancestor", mergeBase, mergeTargetBranch],
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
mergeBaseReachable = true;
|
||||
} catch {
|
||||
mergeBaseReachable = false;
|
||||
}
|
||||
}
|
||||
if (mergeBaseReachable) {
|
||||
return { kind: "proven-no-op", baseRef: mergeTargetBranch, ownDiffEmpty: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// merge-base lookup failed — fall through to existing classifications
|
||||
}
|
||||
}
|
||||
|
||||
let branchExists = false;
|
||||
try {
|
||||
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: rootDir });
|
||||
@@ -6401,6 +6458,142 @@ export async function aiMergeTask(
|
||||
}
|
||||
};
|
||||
|
||||
// FN-5345/FN-5377: early empty-own-diff fast-path.
|
||||
//
|
||||
// Detect branches whose own commits introduce zero net tree change vs their
|
||||
// merge-base with the integration target ("empty-own-diff") BEFORE attempting
|
||||
// any reuse-handoff acquisition. This unsticks tasks where a stale empty
|
||||
// handoff commit (e.g. a verification-only task that committed --allow-empty)
|
||||
// combined with drifted worktree<->branch mapping would otherwise wedge the
|
||||
// handoff gate with `registered-branch-mismatch` and ultimately escalate to
|
||||
// `merge-deadlock-detected: verified content not on main` after FN-4999
|
||||
// completion-handoff-limbo recovery exhausts.
|
||||
//
|
||||
// Scope is narrow on purpose:
|
||||
// - ONLY in reuse-task-worktree integration mode (where the wedge lives).
|
||||
// - ONLY when the branch exists, is ahead of the integration target by >= 1
|
||||
// commit, and `git diff --quiet <mergeBase>..<branchTip>` reports an
|
||||
// empty net diff.
|
||||
// - aheadCount === 0 (already-landed) and missing-branch (no-changes-finalized)
|
||||
// still go through the standard reuse-handoff path so the existing
|
||||
// handoff lease lifecycle and FN-5083 branch-rebind invariants are
|
||||
// preserved.
|
||||
// - cwd-main integration mode (legacy / unit-test default) is unchanged.
|
||||
const earlyFastPathEligible = settings.mergeIntegrationWorktree !== "cwd-main";
|
||||
try {
|
||||
if (!earlyFastPathEligible) throw new Error("skip-early-fast-path:not-reuse-mode");
|
||||
const earlyBranch = task.branch || canonicalFusionBranchName(taskId);
|
||||
let earlyBranchExists = false;
|
||||
try {
|
||||
await execAsync(
|
||||
`git show-ref --verify --quiet ${quoteArg(`refs/heads/${earlyBranch}`)}`,
|
||||
{ cwd: projectRootDir, timeout: 30_000 },
|
||||
);
|
||||
earlyBranchExists = true;
|
||||
} catch {
|
||||
earlyBranchExists = false;
|
||||
}
|
||||
if (earlyBranchExists) {
|
||||
let earlyAheadCount: number | null = null;
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --count ${quoteArg(`${mergeTarget.branch}..${earlyBranch}`)}`,
|
||||
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
|
||||
);
|
||||
const parsed = Number.parseInt(stdout.trim(), 10);
|
||||
if (Number.isFinite(parsed)) earlyAheadCount = parsed;
|
||||
} catch {
|
||||
earlyAheadCount = null;
|
||||
}
|
||||
if (earlyAheadCount !== null && earlyAheadCount > 0) {
|
||||
let earlyMergeBase = "";
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git merge-base ${quoteArg(mergeTarget.branch)} ${quoteArg(earlyBranch)}`,
|
||||
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
|
||||
);
|
||||
earlyMergeBase = stdout.trim();
|
||||
} catch {
|
||||
earlyMergeBase = "";
|
||||
}
|
||||
if (earlyMergeBase) {
|
||||
let earlyOwnDiffEmpty = false;
|
||||
try {
|
||||
await execAsync(
|
||||
`git diff --quiet ${quoteArg(`${earlyMergeBase}..${earlyBranch}`)}`,
|
||||
{ cwd: projectRootDir, timeout: 30_000 },
|
||||
);
|
||||
earlyOwnDiffEmpty = true;
|
||||
} catch {
|
||||
earlyOwnDiffEmpty = false;
|
||||
}
|
||||
if (earlyOwnDiffEmpty) {
|
||||
const noOpReason = `early fast-path: branch ${earlyBranch} has ${earlyAheadCount} own commit(s) but zero net diff vs merge-base of ${mergeTarget.branch}`;
|
||||
const mergeDetails: MergeDetails = {
|
||||
...(task.mergeDetails || {}),
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason,
|
||||
landedFiles: [],
|
||||
mergedAt: new Date().toISOString(),
|
||||
prNumber: task.prInfo?.number,
|
||||
mergeTargetBranch: mergeTarget.branch,
|
||||
};
|
||||
await store.updateTask(taskId, { mergeDetails, modifiedFiles: [] });
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Auto-finalized no-op (early fast-path, FN-5345/FN-5377): ${noOpReason}`,
|
||||
);
|
||||
try {
|
||||
await audit.database({
|
||||
type: "task:auto-recover-finalize-already-on-main",
|
||||
target: taskId,
|
||||
metadata: {
|
||||
phase: "merge",
|
||||
reason: "empty-own-diff-early-fast-path",
|
||||
baseRef: mergeTarget.branch,
|
||||
branch: earlyBranch,
|
||||
aheadCount: earlyAheadCount,
|
||||
mergeBase: earlyMergeBase,
|
||||
},
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: failed to emit empty-own-diff-early-fast-path audit: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
||||
);
|
||||
}
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
branch: earlyBranch,
|
||||
merged: true,
|
||||
noOp: true,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason,
|
||||
mergedAt: mergeDetails.mergedAt,
|
||||
mergeTargetBranch: mergeTarget.branch,
|
||||
};
|
||||
await completeTask(store, taskId, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (earlyErr: unknown) {
|
||||
// Fail-soft: any error falls through to the existing merge path.
|
||||
// (The "skip-early-fast-path:not-reuse-mode" sentinel is the cwd-main bypass
|
||||
// and is intentionally silent.)
|
||||
if (earlyErr instanceof Error && earlyErr.message.startsWith("skip-early-fast-path:")) {
|
||||
// intentional bypass
|
||||
} else {
|
||||
mergerLog.warn(
|
||||
`${taskId}: early empty-own-diff fast-path failed; falling through to standard merge path: ${earlyErr instanceof Error ? earlyErr.message : String(earlyErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main"
|
||||
? "cwd-main"
|
||||
: "reuse-task-worktree";
|
||||
@@ -6420,6 +6613,124 @@ export async function aiMergeTask(
|
||||
reason: string,
|
||||
diagnostics: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
// FN-5345/FN-5377: consult existing registration of `fusion/<id>` before
|
||||
// creating a fresh worktree. If the branch is already registered at a
|
||||
// usable extant path, rebind `task.worktree` to it (avoids FN-5083-class
|
||||
// double-registration where two worktrees both claim the same branch and
|
||||
// the next handoff gate refuses with `registered-branch-mismatch`).
|
||||
//
|
||||
// If registered at a stale/missing path, run `git worktree prune` so the
|
||||
// subsequent `worktree add -f` does not produce a duplicate admin entry.
|
||||
const expectedBranch = task.branch || canonicalFusionBranchName(taskId);
|
||||
try {
|
||||
const { stdout: porcelain } = await execAsync(
|
||||
`git worktree list --porcelain`,
|
||||
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
|
||||
);
|
||||
const entries: { path?: string; branch?: string }[] = [];
|
||||
let current: { path?: string; branch?: string } = {};
|
||||
for (const line of porcelain.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
if (current.path) entries.push(current);
|
||||
current = { path: line.slice("worktree ".length).trim() };
|
||||
} else if (line.startsWith("branch ")) {
|
||||
// refs/heads/fusion/fn-5345 -> fusion/fn-5345
|
||||
current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
|
||||
} else if (line.trim() === "") {
|
||||
if (current.path) {
|
||||
entries.push(current);
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current.path) entries.push(current);
|
||||
const expectedLower = expectedBranch.toLowerCase();
|
||||
const matches = entries.filter((e) => (e.branch || "").toLowerCase() === expectedLower);
|
||||
let prunedAnyStaleRegistration = false;
|
||||
let reusableMatch: { path: string; branch: string } | null = null;
|
||||
for (const match of matches) {
|
||||
if (!match.path) continue;
|
||||
const exists = existsSync(match.path);
|
||||
if (!exists) {
|
||||
prunedAnyStaleRegistration = true;
|
||||
continue;
|
||||
}
|
||||
const cls = await classifyTaskWorktree(projectRootDir, match.path);
|
||||
if (cls.ok) {
|
||||
reusableMatch = { path: match.path, branch: match.branch || expectedBranch };
|
||||
break;
|
||||
}
|
||||
prunedAnyStaleRegistration = true;
|
||||
}
|
||||
if (prunedAnyStaleRegistration) {
|
||||
try {
|
||||
await execAsync(`git worktree prune`, { cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 });
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-fallback-new-worktree",
|
||||
{
|
||||
taskId,
|
||||
reason: "pruned-stale-branch-registration",
|
||||
branch: expectedBranch,
|
||||
diagnostics: { matches: matches.map((m) => ({ path: m.path, branch: m.branch })) },
|
||||
prePrune: true,
|
||||
},
|
||||
projectRootDir,
|
||||
);
|
||||
} catch (pruneErr) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: git worktree prune failed before reacquire: ${pruneErr instanceof Error ? pruneErr.message : String(pruneErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (reusableMatch) {
|
||||
// Reuse the extant registration directly. Skip acquireTaskWorktree's
|
||||
// fresh-create path so we don't double-register the branch.
|
||||
task.worktree = reusableMatch.path;
|
||||
task.branch = reusableMatch.branch;
|
||||
branch = reusableMatch.branch;
|
||||
integrationRoot = {
|
||||
...integrationRoot,
|
||||
mode: "reuse-task-worktree",
|
||||
rootDir: reusableMatch.path,
|
||||
branchName: reusableMatch.branch,
|
||||
};
|
||||
reuseTaskWorktreeMerge = true;
|
||||
rootDir = reusableMatch.path;
|
||||
integrationRemote = await resolveIntegrationRemote({
|
||||
settings,
|
||||
rootDir,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
});
|
||||
await store.updateTask(taskId, { worktree: reusableMatch.path, branch: reusableMatch.branch });
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-fallback-new-worktree",
|
||||
{
|
||||
taskId,
|
||||
reason: `${reason}:reused-existing-registration`,
|
||||
branch: reusableMatch.branch,
|
||||
worktreePath: reusableMatch.path,
|
||||
source: "existing",
|
||||
diagnostics,
|
||||
integrationRemote: integrationRemote ?? null,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
},
|
||||
reusableMatch.path,
|
||||
);
|
||||
await store.recordActivity({
|
||||
type: "task:merge-worktree-reacquired",
|
||||
taskId,
|
||||
taskTitle: task.title,
|
||||
details: `Merge worktree reacquired from existing registration: ${reason}`,
|
||||
metadata: { reason, branch: reusableMatch.branch, worktreePath: reusableMatch.path, source: "existing" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (listErr) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: git worktree list consult failed before reacquire; proceeding with fresh creation: ${listErr instanceof Error ? listErr.message : String(listErr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const acquisition = await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: projectRootDir,
|
||||
|
||||
@@ -7,6 +7,7 @@ const execAsync = promisify(exec);
|
||||
|
||||
export const DEFAULT_ALLOWED_BRANCH_PATTERNS = ["^fusion/step-\\d+-[a-z0-9-]+$"] as const;
|
||||
const COMMIT_MSG_HOOK_MARKER = "# fusion-managed-commit-msg-hook";
|
||||
const PREPARE_COMMIT_MSG_HOOK_MARKER = "# fusion-managed-prepare-commit-msg-hook";
|
||||
|
||||
function toShellCasePattern(pattern: string): string {
|
||||
return pattern
|
||||
@@ -36,6 +37,12 @@ if [ ! -f "$TASK_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Note: empty-commit refusal (FN-5345/FN-5377) lives in the
|
||||
# prepare-commit-msg hook (installed by installTaskWorktreeIdentityGuard).
|
||||
# That hook gets the commit-source argument and can distinguish 'amend' /
|
||||
# 'merge' / 'squash' (which legitimately may produce empty commits) from
|
||||
# new commits with --allow-empty.
|
||||
|
||||
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
|
||||
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
|
||||
EXPECTED_BRANCH=${JSON.stringify(`fusion/${taskId.toLowerCase()}`)}
|
||||
@@ -73,6 +80,66 @@ async function resolveGitPath(worktreePath: string, gitPath: string): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shared prepare-commit-msg empty-commit guard hook.
|
||||
*
|
||||
* Refuses to author empty commits in fusion-managed task worktrees. A
|
||||
* verification-only task that finds no repro should leave own-commits at 0
|
||||
* and let the merger's empty-own-diff / proven-no-op classifier finalize it.
|
||||
* Manufacturing an empty handoff commit (e.g. `git commit --allow-empty`)
|
||||
* defeats that classifier and wedges the task in the merge path (FN-5345).
|
||||
*
|
||||
* prepare-commit-msg gets the commit-source argument so we can correctly
|
||||
* allow legitimate empty commits during amend / merge / squash / template
|
||||
* paths and only refuse new --allow-empty commits.
|
||||
*/
|
||||
export function buildPrepareCommitMsgEmptyGuardHook(taskId: string): string {
|
||||
return `#!/bin/sh
|
||||
set -eu
|
||||
${PREPARE_COMMIT_MSG_HOOK_MARKER}
|
||||
# fusion-task-id-seed: ${taskId}
|
||||
|
||||
# Only enforce in fusion-managed task worktrees.
|
||||
TASK_FILE=$(git rev-parse --git-path fusion-task-id)
|
||||
[ -f "$TASK_FILE" ] || exit 0
|
||||
|
||||
COMMIT_SOURCE="\${2:-}"
|
||||
|
||||
# Allow legitimate paths that may produce empty commits:
|
||||
# - commit (amend via --amend with no -m)
|
||||
# - merge (merge commit)
|
||||
# - squash (squash merge)
|
||||
# - cherry-pick / revert / rebase ceremonies (detected via git-dir markers)
|
||||
case "$COMMIT_SOURCE" in
|
||||
commit|merge|squash) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 'git commit --amend -m "..."' reports source=message (not commit), so the
|
||||
# source arg alone cannot distinguish amend-with-new-message from
|
||||
# --allow-empty -m. Inspect the parent process command line as a tiebreaker.
|
||||
PARENT_CMD=$(ps -o args= -p "$PPID" 2>/dev/null || echo "")
|
||||
case "$PARENT_CMD" in
|
||||
*' --amend'*|*' --amend '*) exit 0 ;;
|
||||
esac
|
||||
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
if [ -f "$GIT_DIR/MERGE_HEAD" ] \\
|
||||
|| [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] \\
|
||||
|| [ -f "$GIT_DIR/REVERT_HEAD" ] \\
|
||||
|| [ -d "$GIT_DIR/rebase-merge" ] \\
|
||||
|| [ -d "$GIT_DIR/rebase-apply" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git diff --cached --quiet --no-ext-diff 2>/dev/null; then
|
||||
printf '%s\\n' "fusion: refusing empty commit \u2014 staged diff is empty." >&2
|
||||
printf '%s\\n' " Use fn_task_document_write for narrative output, not git commits." >&2
|
||||
printf '%s\\n' " (FN-5345/FN-5377 empty-commit guard)" >&2
|
||||
exit 1
|
||||
fi
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildCommitMsgTrailerHook(
|
||||
taskId: string,
|
||||
options: {
|
||||
@@ -141,6 +208,23 @@ async function installCommitMsgHook(input: {
|
||||
await writeFileAtomic(hookPath, hook, 0o755);
|
||||
}
|
||||
|
||||
async function installPrepareCommitMsgEmptyGuard(input: {
|
||||
worktreePath: string;
|
||||
taskId: string;
|
||||
}): Promise<void> {
|
||||
const hookPath = await resolveGitPath(input.worktreePath, "hooks/prepare-commit-msg");
|
||||
const existing = await fs.readFile(hookPath, "utf-8").catch(() => null);
|
||||
if (existing && !existing.includes(PREPARE_COMMIT_MSG_HOOK_MARKER)) {
|
||||
console.warn(
|
||||
`[worktree-hooks] prepare-commit-msg hook already exists at ${hookPath}; skipping Fusion empty-commit guard install for ${input.taskId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hook = buildPrepareCommitMsgEmptyGuardHook(input.taskId);
|
||||
await writeFileAtomic(hookPath, hook, 0o755);
|
||||
}
|
||||
|
||||
export async function installTaskWorktreeIdentityGuard(input: {
|
||||
worktreePath: string;
|
||||
taskId: string;
|
||||
@@ -164,4 +248,11 @@ export async function installTaskWorktreeIdentityGuard(input: {
|
||||
trailerName: input.taskAttributionTrailerName ?? "Fusion-Task-Id",
|
||||
});
|
||||
}
|
||||
|
||||
// FN-5345/FN-5377: install the empty-commit guard alongside the trailer
|
||||
// hook. Skipped automatically if a non-fusion hook already exists.
|
||||
await installPrepareCommitMsgEmptyGuard({
|
||||
worktreePath: input.worktreePath,
|
||||
taskId: input.taskId,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user