feat(FN-5350): switch merge integration advance to ref-only git update-ref
Implements ref-only integration branch advancement via `git update-ref` instead of checkout+commit, including a new `merger-ref-update-advance.ts` helper, a reduced and clarified `merger.ts`, comprehensive unit and real-git regression coverage, and audit event wiring. Fusion-Task-Id: FN-5350
This commit is contained in:
committed by
gsxdsm
parent
109f969f06
commit
caeb6a7e00
@@ -311,6 +311,7 @@ Hard-won rules (FN-2370 silently reverted three commits' worth of work):
|
|||||||
8. **Smart-prefer-main overlap guard.** When `mergeConflictStrategy="smart-prefer-main"`, recent main commits (30-commit lookback) overlapping branch-modified files flip to prefer-branch by default (`mergeStrategyOverlapBehavior="flip-to-prefer-branch"`).
|
8. **Smart-prefer-main overlap guard.** When `mergeConflictStrategy="smart-prefer-main"`, recent main commits (30-commit lookback) overlapping branch-modified files flip to prefer-branch by default (`mergeStrategyOverlapBehavior="flip-to-prefer-branch"`).
|
||||||
9. **Layer 3 scope partition for AI arbitration (FN-4956).** Before handing conflicted files to the Layer 3 AI arbiter, merger partitions conflicts against declared task File Scope. Out-of-scope conflicts are resolved to main (`--ours`) and unstaged so they cannot enter the squash; only in-scope conflicts reach AI. `task.scopeOverride=true` bypasses this partition. Run-audit emits `merge:layer3:foreign-file-skipped` (skip path) or `merge:layer3:scope-override-bypass` (override path).
|
9. **Layer 3 scope partition for AI arbitration (FN-4956).** Before handing conflicted files to the Layer 3 AI arbiter, merger partitions conflicts against declared task File Scope. Out-of-scope conflicts are resolved to main (`--ours`) and unstaged so they cannot enter the squash; only in-scope conflicts reach AI. `task.scopeOverride=true` bypasses this partition. Run-audit emits `merge:layer3:foreign-file-skipped` (skip path) or `merge:layer3:scope-override-bypass` (override path).
|
||||||
10. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 1–3 cascade; worktrunk-enabled paths defer this layer.
|
10. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 1–3 cascade; worktrunk-enabled paths defer this layer.
|
||||||
|
11. **Integration branch advance is ref-only (FN-5350).** After the task worktree squash succeeds, the merger advances `refs/heads/<integration-branch>` via `git update-ref refs/heads/<integration> <new-sha> <expected-current-sha>` against the task-worktree git root, never via `git checkout <integration> && git merge --ff-only`. Compare-and-swap (`expected-current-sha`) preserves the concurrent-advance rule: if integration moved between detach and advance, `update-ref` refuses, the merger throws `IntegrationBranchConcurrentAdvanceError`, the task parks in `in-review` (`status: "failed"`), and upstream re-rebase machinery (FN-4500 / FN-5083 / standard re-execution) recovers on the next pass. Dirty + untracked files in the user's checked-out integration-branch worktree at `projectRootDir` are never touched and never block a merge. On successful advance, the merger logs `<integration> advanced to <sha> via update-ref; your checked-out worktree at <projectRootDir> is now behind` — informational, not an error.
|
||||||
|
|
||||||
### Gitignored-path guard on squash merges
|
### Gitignored-path guard on squash merges
|
||||||
|
|
||||||
|
|||||||
162
packages/engine/src/__tests__/merger-ref-update-advance.test.ts
Normal file
162
packages/engine/src/__tests__/merger-ref-update-advance.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { advanceIntegrationBranchRef } from "../merger-ref-update-advance.js";
|
||||||
|
|
||||||
|
function git(cwd: string, cmd: string): string {
|
||||||
|
return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRepo(defaultBranch: "main" | "master" = "main") {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "fusion-test-ref-advance-"));
|
||||||
|
git(dir, `git init -b ${defaultBranch}`);
|
||||||
|
git(dir, "git config user.name tester");
|
||||||
|
git(dir, "git config user.email tester@example.com");
|
||||||
|
writeFileSync(join(dir, "tracked.txt"), "one\n");
|
||||||
|
git(dir, "git add tracked.txt");
|
||||||
|
git(dir, "git commit -m init");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("advanceIntegrationBranchRef", () => {
|
||||||
|
it.each(["main", "master"] as const)("advances %s via update-ref happy path", async (integrationBranch) => {
|
||||||
|
const dir = setupRepo(integrationBranch);
|
||||||
|
const events: Array<{ type: string; target?: string; metadata?: Record<string, unknown> }> = [];
|
||||||
|
try {
|
||||||
|
const expectedCurrentSha = git(dir, `git rev-parse refs/heads/${integrationBranch}`);
|
||||||
|
git(dir, "git checkout -b feat");
|
||||||
|
writeFileSync(join(dir, "feature.txt"), "feature\n");
|
||||||
|
git(dir, "git add feature.txt");
|
||||||
|
git(dir, "git commit -m feat");
|
||||||
|
const newSha = git(dir, "git rev-parse HEAD");
|
||||||
|
|
||||||
|
const result = await advanceIntegrationBranchRef({
|
||||||
|
rootDir: dir,
|
||||||
|
projectRootDir: dir,
|
||||||
|
integrationBranch,
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
taskId: "FN-5350",
|
||||||
|
audit: {
|
||||||
|
git: async (event: any) => events.push(event),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ advanced: true, previousSha: expectedCurrentSha, newSha });
|
||||||
|
expect(git(dir, `git rev-parse refs/heads/${integrationBranch}`)).toBe(newSha);
|
||||||
|
expect(events[0]?.type).toBe("merge:reuse-integration-branch-advanced");
|
||||||
|
expect(events[0]?.metadata?.via).toBe("update-ref");
|
||||||
|
expect(events[0]?.target).toBe(integrationBranch);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns concurrent-advance when observed tip differs from expected", async () => {
|
||||||
|
const dir = setupRepo("main");
|
||||||
|
const events: Array<{ type: string; metadata?: Record<string, unknown> }> = [];
|
||||||
|
try {
|
||||||
|
const staleExpectedSha = git(dir, "git rev-parse refs/heads/main");
|
||||||
|
git(dir, "git checkout -b other");
|
||||||
|
writeFileSync(join(dir, "other.txt"), "other\n");
|
||||||
|
git(dir, "git add other.txt");
|
||||||
|
git(dir, "git commit -m other");
|
||||||
|
const observedCurrentSha = git(dir, "git rev-parse HEAD");
|
||||||
|
git(dir, `git update-ref refs/heads/main ${observedCurrentSha} ${staleExpectedSha}`);
|
||||||
|
|
||||||
|
git(dir, "git checkout -b feat2");
|
||||||
|
writeFileSync(join(dir, "feature2.txt"), "feature2\n");
|
||||||
|
git(dir, "git add feature2.txt");
|
||||||
|
git(dir, "git commit -m feat2");
|
||||||
|
const newSha = git(dir, "git rev-parse HEAD");
|
||||||
|
|
||||||
|
const result = await advanceIntegrationBranchRef({
|
||||||
|
rootDir: dir,
|
||||||
|
projectRootDir: dir,
|
||||||
|
integrationBranch: "main",
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha: staleExpectedSha,
|
||||||
|
taskId: "FN-5350",
|
||||||
|
audit: {
|
||||||
|
git: async (event: any) => events.push(event),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.advanced).toBe(false);
|
||||||
|
if (result.advanced) throw new Error("expected refusal");
|
||||||
|
expect(result.reason).toBe("concurrent-advance");
|
||||||
|
expect(result.observedCurrentSha).toBe(observedCurrentSha);
|
||||||
|
expect(git(dir, "git rev-parse refs/heads/main")).toBe(observedCurrentSha);
|
||||||
|
expect(events[0]?.type).toBe("merge:reuse-integration-branch-advance-failed");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps dirty and untracked files untouched while advancing", async () => {
|
||||||
|
const dir = setupRepo("main");
|
||||||
|
try {
|
||||||
|
const expectedCurrentSha = git(dir, "git rev-parse refs/heads/main");
|
||||||
|
git(dir, "git checkout -b feat");
|
||||||
|
writeFileSync(join(dir, "feature.txt"), "feature\n");
|
||||||
|
git(dir, "git add feature.txt");
|
||||||
|
git(dir, "git commit -m feat");
|
||||||
|
const newSha = git(dir, "git rev-parse HEAD");
|
||||||
|
git(dir, "git checkout main");
|
||||||
|
|
||||||
|
writeFileSync(join(dir, "tracked.txt"), "one\nuser-local-edit\n");
|
||||||
|
writeFileSync(join(dir, "untracked.txt"), "untracked\n");
|
||||||
|
const trackedBefore = readFileSync(join(dir, "tracked.txt"), "utf-8");
|
||||||
|
const untrackedBefore = readFileSync(join(dir, "untracked.txt"), "utf-8");
|
||||||
|
|
||||||
|
const result = await advanceIntegrationBranchRef({
|
||||||
|
rootDir: dir,
|
||||||
|
projectRootDir: dir,
|
||||||
|
integrationBranch: "main",
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
taskId: "FN-5350",
|
||||||
|
audit: { git: async () => undefined } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.advanced).toBe(true);
|
||||||
|
expect(readFileSync(join(dir, "tracked.txt"), "utf-8")).toBe(trackedBefore);
|
||||||
|
expect(readFileSync(join(dir, "untracked.txt"), "utf-8")).toBe(untrackedBefore);
|
||||||
|
expect(existsSync(join(dir, "untracked.txt"))).toBe(true);
|
||||||
|
const status = git(dir, "git status --porcelain");
|
||||||
|
expect(status).toContain("tracked.txt");
|
||||||
|
expect(status).toContain("untracked.txt");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on missing precondition shas", async () => {
|
||||||
|
const dir = setupRepo("main");
|
||||||
|
try {
|
||||||
|
await expect(advanceIntegrationBranchRef({
|
||||||
|
rootDir: dir,
|
||||||
|
projectRootDir: dir,
|
||||||
|
integrationBranch: "main",
|
||||||
|
newSha: "",
|
||||||
|
expectedCurrentSha: "abc",
|
||||||
|
taskId: "FN-5350",
|
||||||
|
audit: { git: async () => undefined } as any,
|
||||||
|
})).rejects.toThrow("newSha");
|
||||||
|
|
||||||
|
await expect(advanceIntegrationBranchRef({
|
||||||
|
rootDir: dir,
|
||||||
|
projectRootDir: dir,
|
||||||
|
integrationBranch: "main",
|
||||||
|
newSha: "abc",
|
||||||
|
expectedCurrentSha: "",
|
||||||
|
taskId: "FN-5350",
|
||||||
|
audit: { git: async () => undefined } as any,
|
||||||
|
})).rejects.toThrow("expectedCurrentSha");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { git, hasGit } from "./_helpers.js";
|
||||||
|
import { advanceIntegrationBranchRef, __test__ } from "../../merger-ref-update-advance.js";
|
||||||
|
|
||||||
|
describe.skipIf(!hasGit)("reliability interaction: dirty integration worktree with ref-only advance", () => {
|
||||||
|
it.each(["main", "master"] as const)("advances %s without touching dirty/untracked files", async (integrationBranch) => {
|
||||||
|
const projectRootDir = mkdtempSync(join(tmpdir(), "fusion-test-ref-project-"));
|
||||||
|
const rootDir = join(projectRootDir, "task-wt");
|
||||||
|
const events: any[] = [];
|
||||||
|
const runGitSpy = vi.spyOn(__test__, "runGit");
|
||||||
|
try {
|
||||||
|
git(projectRootDir, `git init -b ${integrationBranch}`);
|
||||||
|
git(projectRootDir, "git config user.name tester");
|
||||||
|
git(projectRootDir, "git config user.email tester@example.com");
|
||||||
|
writeFileSync(join(projectRootDir, "tracked.ts"), "export const a = 1;\n");
|
||||||
|
git(projectRootDir, "git add tracked.ts");
|
||||||
|
git(projectRootDir, "git commit -m init");
|
||||||
|
|
||||||
|
const expectedCurrentSha = git(projectRootDir, `git rev-parse refs/heads/${integrationBranch}`);
|
||||||
|
git(projectRootDir, "git checkout -b feature");
|
||||||
|
writeFileSync(join(projectRootDir, "feature.ts"), "export const feature = true;\n");
|
||||||
|
git(projectRootDir, "git add feature.ts");
|
||||||
|
git(projectRootDir, "git commit -m feature");
|
||||||
|
const newSha = git(projectRootDir, "git rev-parse HEAD");
|
||||||
|
git(projectRootDir, `git checkout ${integrationBranch}`);
|
||||||
|
git(projectRootDir, `git branch task-wt feature`);
|
||||||
|
git(projectRootDir, `git worktree add ${JSON.stringify(rootDir)} task-wt`);
|
||||||
|
|
||||||
|
writeFileSync(join(projectRootDir, "tracked.ts"), "export const a = 1;\nuser-local-edit\n");
|
||||||
|
writeFileSync(join(projectRootDir, "new-untracked.txt"), "untracked\n");
|
||||||
|
const trackedBefore = readFileSync(join(projectRootDir, "tracked.ts"), "utf-8");
|
||||||
|
const untrackedBefore = readFileSync(join(projectRootDir, "new-untracked.txt"), "utf-8");
|
||||||
|
|
||||||
|
const result = await advanceIntegrationBranchRef({
|
||||||
|
rootDir,
|
||||||
|
projectRootDir,
|
||||||
|
integrationBranch,
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
taskId: "FN-5350",
|
||||||
|
audit: { git: async (event: any) => events.push(event) } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ advanced: true, previousSha: expectedCurrentSha, newSha });
|
||||||
|
expect(git(projectRootDir, `git rev-parse refs/heads/${integrationBranch}`)).toBe(newSha);
|
||||||
|
expect(readFileSync(join(projectRootDir, "tracked.ts"), "utf-8")).toBe(trackedBefore);
|
||||||
|
expect(readFileSync(join(projectRootDir, "new-untracked.txt"), "utf-8")).toBe(untrackedBefore);
|
||||||
|
expect(existsSync(join(projectRootDir, "new-untracked.txt"))).toBe(true);
|
||||||
|
expect(git(projectRootDir, "git status --porcelain")).toContain("new-untracked.txt");
|
||||||
|
|
||||||
|
const updateRefCalls = runGitSpy.mock.calls.filter(([args]) => Array.isArray(args) && args[0] === "update-ref");
|
||||||
|
expect(updateRefCalls).toHaveLength(1);
|
||||||
|
expect(updateRefCalls[0]?.[1]).toBe(rootDir);
|
||||||
|
expect(runGitSpy.mock.calls.some(([args, cwd]) => {
|
||||||
|
if (cwd !== projectRootDir || !Array.isArray(args)) return false;
|
||||||
|
return ["checkout", "merge", "rebase", "update-ref"].includes(args[0] ?? "");
|
||||||
|
})).toBe(false);
|
||||||
|
|
||||||
|
const advanceEvent = events.find((event) => event.type === "merge:reuse-integration-branch-advanced");
|
||||||
|
expect(advanceEvent?.metadata?.via).toBe("update-ref");
|
||||||
|
expect(advanceEvent?.target).toBe(integrationBranch);
|
||||||
|
if (integrationBranch === "master") {
|
||||||
|
expect(JSON.stringify(advanceEvent)).not.toContain('"main"');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runGitSpy.mockRestore();
|
||||||
|
rmSync(projectRootDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns concurrent-advance and preserves concurrent ref", async () => {
|
||||||
|
const projectRootDir = mkdtempSync(join(tmpdir(), "fusion-test-ref-concurrent-project-"));
|
||||||
|
const rootDir = join(projectRootDir, "task-wt");
|
||||||
|
const events: any[] = [];
|
||||||
|
try {
|
||||||
|
git(projectRootDir, "git init -b main");
|
||||||
|
git(projectRootDir, "git config user.name tester");
|
||||||
|
git(projectRootDir, "git config user.email tester@example.com");
|
||||||
|
writeFileSync(join(projectRootDir, "tracked.ts"), "export const a = 1;\n");
|
||||||
|
git(projectRootDir, "git add tracked.ts");
|
||||||
|
git(projectRootDir, "git commit -m init");
|
||||||
|
|
||||||
|
const expectedCurrentSha = git(projectRootDir, "git rev-parse refs/heads/main");
|
||||||
|
git(projectRootDir, "git checkout -b concurrent");
|
||||||
|
writeFileSync(join(projectRootDir, "concurrent.ts"), "export const concurrent = 1;\n");
|
||||||
|
git(projectRootDir, "git add concurrent.ts");
|
||||||
|
git(projectRootDir, "git commit -m concurrent");
|
||||||
|
const observedCurrentSha = git(projectRootDir, "git rev-parse HEAD");
|
||||||
|
git(projectRootDir, `git update-ref refs/heads/main ${observedCurrentSha} ${expectedCurrentSha}`);
|
||||||
|
|
||||||
|
git(projectRootDir, "git checkout -b feature");
|
||||||
|
writeFileSync(join(projectRootDir, "feature.ts"), "export const feature = true;\n");
|
||||||
|
git(projectRootDir, "git add feature.ts");
|
||||||
|
git(projectRootDir, "git commit -m feature");
|
||||||
|
const newSha = git(projectRootDir, "git rev-parse HEAD");
|
||||||
|
git(projectRootDir, "git checkout main");
|
||||||
|
git(projectRootDir, "git branch task-wt feature");
|
||||||
|
git(projectRootDir, `git worktree add ${JSON.stringify(rootDir)} task-wt`);
|
||||||
|
|
||||||
|
const result = await advanceIntegrationBranchRef({
|
||||||
|
rootDir,
|
||||||
|
projectRootDir,
|
||||||
|
integrationBranch: "main",
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
taskId: "FN-5350",
|
||||||
|
audit: { git: async (event: any) => events.push(event) } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.advanced).toBe(false);
|
||||||
|
if (result.advanced) throw new Error("expected refusal");
|
||||||
|
expect(result.reason).toBe("concurrent-advance");
|
||||||
|
expect(git(projectRootDir, "git rev-parse refs/heads/main")).toBe(observedCurrentSha);
|
||||||
|
const failureEvent = events.find((event) => event.type === "merge:reuse-integration-branch-advance-failed");
|
||||||
|
expect(failureEvent?.metadata?.reason).toBe("concurrent-advance");
|
||||||
|
expect(failureEvent?.metadata?.expectedCurrentSha).toBe(expectedCurrentSha);
|
||||||
|
expect(failureEvent?.metadata?.observedCurrentSha).toBe(observedCurrentSha);
|
||||||
|
} finally {
|
||||||
|
rmSync(projectRootDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,9 +78,11 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
|||||||
const advanced = audits.find(
|
const advanced = audits.find(
|
||||||
(event) => event.mutationType === "merge:reuse-integration-branch-advanced",
|
(event) => event.mutationType === "merge:reuse-integration-branch-advanced",
|
||||||
);
|
);
|
||||||
expect(advanced?.metadata).toMatchObject({ via: "ff-merge" });
|
expect(advanced?.metadata).toMatchObject({ via: "update-ref" });
|
||||||
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
|
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
|
||||||
expect(git(rootDir, "git status --porcelain --untracked-files=no")).toBe(rootTrackedStatusBefore);
|
const rootTrackedStatusAfter = git(rootDir, "git status --porcelain --untracked-files=no");
|
||||||
|
expect(rootTrackedStatusAfter).not.toBe(rootTrackedStatusBefore);
|
||||||
|
expect(rootTrackedStatusAfter).toContain("fn-5279-ri-happy.ts");
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.cleanup();
|
await fixture.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
188
packages/engine/src/merger-ref-update-advance.ts
Normal file
188
packages/engine/src/merger-ref-update-advance.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { RunAuditor } from "./run-audit.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
async function runGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return await execFileAsync("git", args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const testHooks = {
|
||||||
|
runGit,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class IntegrationBranchConcurrentAdvanceError extends Error {
|
||||||
|
readonly integrationBranch: string;
|
||||||
|
readonly expectedCurrentSha: string;
|
||||||
|
readonly observedCurrentSha?: string;
|
||||||
|
readonly newSha: string;
|
||||||
|
readonly taskId: string;
|
||||||
|
|
||||||
|
constructor(args: {
|
||||||
|
integrationBranch: string;
|
||||||
|
expectedCurrentSha: string;
|
||||||
|
observedCurrentSha?: string;
|
||||||
|
newSha: string;
|
||||||
|
taskId: string;
|
||||||
|
}) {
|
||||||
|
const { integrationBranch, expectedCurrentSha, observedCurrentSha, newSha, taskId } = args;
|
||||||
|
super(
|
||||||
|
`Integration branch ${integrationBranch} advanced concurrently (expected ${expectedCurrentSha}, observed ${observedCurrentSha ?? "unknown"}) while applying ${newSha} for ${taskId}`,
|
||||||
|
);
|
||||||
|
this.name = "IntegrationBranchConcurrentAdvanceError";
|
||||||
|
this.integrationBranch = integrationBranch;
|
||||||
|
this.expectedCurrentSha = expectedCurrentSha;
|
||||||
|
this.observedCurrentSha = observedCurrentSha;
|
||||||
|
this.newSha = newSha;
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function advanceIntegrationBranchRef(args: {
|
||||||
|
rootDir: string;
|
||||||
|
projectRootDir: string;
|
||||||
|
integrationBranch: string;
|
||||||
|
newSha: string;
|
||||||
|
expectedCurrentSha: string;
|
||||||
|
taskId: string;
|
||||||
|
audit: RunAuditor;
|
||||||
|
}): Promise<
|
||||||
|
| { advanced: true; previousSha: string; newSha: string }
|
||||||
|
| {
|
||||||
|
advanced: false;
|
||||||
|
reason: "concurrent-advance" | "ref-update-refused" | "missing-current-sha";
|
||||||
|
diagnostic: string;
|
||||||
|
observedCurrentSha?: string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
const {
|
||||||
|
rootDir,
|
||||||
|
projectRootDir,
|
||||||
|
integrationBranch,
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
taskId,
|
||||||
|
audit,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
if (!integrationBranch?.trim()) {
|
||||||
|
throw new Error("advanceIntegrationBranchRef requires integrationBranch");
|
||||||
|
}
|
||||||
|
if (!newSha?.trim()) {
|
||||||
|
throw new Error("advanceIntegrationBranchRef requires newSha");
|
||||||
|
}
|
||||||
|
if (!expectedCurrentSha?.trim()) {
|
||||||
|
throw new Error("advanceIntegrationBranchRef requires expectedCurrentSha");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = `refs/heads/${integrationBranch}`;
|
||||||
|
let observedCurrentSha = "";
|
||||||
|
try {
|
||||||
|
const { stdout } = await testHooks.runGit(["rev-parse", "--verify", ref], rootDir);
|
||||||
|
observedCurrentSha = stdout.trim();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const diagnostic = error instanceof Error ? error.message : String(error);
|
||||||
|
await audit.git({
|
||||||
|
type: "merge:reuse-integration-branch-advance-failed",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: {
|
||||||
|
taskId,
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
reason: "missing-current-sha",
|
||||||
|
diagnostic,
|
||||||
|
projectRootDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
advanced: false,
|
||||||
|
reason: "missing-current-sha",
|
||||||
|
diagnostic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!observedCurrentSha) {
|
||||||
|
const diagnostic = `Missing current sha for ${ref}`;
|
||||||
|
await audit.git({
|
||||||
|
type: "merge:reuse-integration-branch-advance-failed",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: {
|
||||||
|
taskId,
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
reason: "missing-current-sha",
|
||||||
|
diagnostic,
|
||||||
|
projectRootDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
advanced: false,
|
||||||
|
reason: "missing-current-sha",
|
||||||
|
diagnostic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observedCurrentSha !== expectedCurrentSha) {
|
||||||
|
const diagnostic = `Expected ${expectedCurrentSha} but observed ${observedCurrentSha} for ${ref}`;
|
||||||
|
await audit.git({
|
||||||
|
type: "merge:reuse-integration-branch-advance-failed",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: {
|
||||||
|
taskId,
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
observedCurrentSha,
|
||||||
|
reason: "concurrent-advance",
|
||||||
|
diagnostic,
|
||||||
|
projectRootDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
advanced: false,
|
||||||
|
reason: "concurrent-advance",
|
||||||
|
diagnostic,
|
||||||
|
observedCurrentSha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testHooks.runGit(["update-ref", ref, newSha, expectedCurrentSha], rootDir);
|
||||||
|
await audit.git({
|
||||||
|
type: "merge:reuse-integration-branch-advanced",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: { taskId, sha: newSha, via: "update-ref", expectedCurrentSha, projectRootDir },
|
||||||
|
});
|
||||||
|
return { advanced: true, previousSha: expectedCurrentSha, newSha };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const diagnostic = error instanceof Error ? error.message : String(error);
|
||||||
|
const lower = diagnostic.toLowerCase();
|
||||||
|
const isConcurrent = lower.includes("cannot lock ref") || lower.includes("is at") || lower.includes("expected");
|
||||||
|
const reason = isConcurrent ? "concurrent-advance" : "ref-update-refused";
|
||||||
|
await audit.git({
|
||||||
|
type: "merge:reuse-integration-branch-advance-failed",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: {
|
||||||
|
taskId,
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
observedCurrentSha,
|
||||||
|
reason,
|
||||||
|
diagnostic,
|
||||||
|
projectRootDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
advanced: false,
|
||||||
|
reason,
|
||||||
|
diagnostic,
|
||||||
|
observedCurrentSha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __test__ = testHooks;
|
||||||
@@ -109,8 +109,10 @@ import {
|
|||||||
} from "./merger-integration-worktree.js";
|
} from "./merger-integration-worktree.js";
|
||||||
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
||||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||||
|
import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
|
||||||
|
|
||||||
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||||
|
export { IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
|
||||||
|
|
||||||
/** Conflict type classification for merge conflict resolution */
|
/** Conflict type classification for merge conflict resolution */
|
||||||
export type ConflictType =
|
export type ConflictType =
|
||||||
@@ -8895,15 +8897,13 @@ export async function aiMergeTask(
|
|||||||
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5c. Apply squash commit to the project root's local integration branch
|
// 5c. Advance integration branch ref after squash
|
||||||
// (FN-5279). In reuse-task-worktree mode step 3b detached HEAD in the task
|
// FN-5350 invariant: the integration branch is assumed checked out in
|
||||||
// worktree, so the squash commit landed on a detached HEAD and the local
|
// projectRootDir with possibly dirty + untracked files. Advance refs/heads/<integration>
|
||||||
// integration branch ref in the project root still points at the pre-merge
|
// via `git update-ref` only — NEVER `git checkout` / `git merge` / `git rebase`
|
||||||
// tip. Advance it now via `git merge --ff-only` so the changes are
|
// in projectRootDir. Compare-and-swap (expectedCurrentSha → newSha) preserves
|
||||||
// actually applied to local main. If main has diverged in the meantime
|
// the FN concurrent-advance rule: if integration moved, we refuse and let
|
||||||
// (rare — merge queue lease should prevent concurrent advances), fall back
|
// upstream re-rebase machinery (FN-4500 / FN-5083 rebind / standard re-execution) recover.
|
||||||
// to a regular merge and let the existing AI conflict resolution helper
|
|
||||||
// resolve any conflicts.
|
|
||||||
if (reuseTaskWorktreeMerge) {
|
if (reuseTaskWorktreeMerge) {
|
||||||
try {
|
try {
|
||||||
const worktreeHeadSha = execSyncText("git rev-parse HEAD", {
|
const worktreeHeadSha = execSyncText("git rev-parse HEAD", {
|
||||||
@@ -8912,122 +8912,48 @@ export async function aiMergeTask(
|
|||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
}).trim();
|
}).trim();
|
||||||
if (worktreeHeadSha) {
|
if (worktreeHeadSha) {
|
||||||
const integrationBranch = mergeTarget.branch;
|
const integrationBranch = mergeTarget.branch || await resolveIntegrationBranch(projectRootDir, settings);
|
||||||
try {
|
const expectedCurrentSha = execSyncText(`git rev-parse --verify ${quoteArg(`refs/heads/${integrationBranch}`)}`, {
|
||||||
await execAsync(`git merge --ff-only ${quoteArg(worktreeHeadSha)}`, {
|
cwd: rootDir,
|
||||||
cwd: projectRootDir,
|
stdio: "pipe",
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 60_000,
|
}).trim();
|
||||||
});
|
const advanceResult = await advanceIntegrationBranchRef({
|
||||||
await (audit as any).git({
|
rootDir,
|
||||||
type: "merge:reuse-integration-branch-advanced",
|
projectRootDir,
|
||||||
target: integrationBranch,
|
integrationBranch,
|
||||||
metadata: { taskId, sha: worktreeHeadSha, via: "ff-merge", projectRootDir },
|
newSha: worktreeHeadSha,
|
||||||
});
|
expectedCurrentSha,
|
||||||
mergerLog.log(
|
taskId,
|
||||||
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root (ff-merge)`,
|
audit,
|
||||||
);
|
});
|
||||||
} catch (ffErr: unknown) {
|
if (!advanceResult.advanced) {
|
||||||
const ffMsg = ffErr instanceof Error ? ffErr.message : String(ffErr);
|
if (advanceResult.reason === "concurrent-advance") {
|
||||||
mergerLog.warn(
|
throw new IntegrationBranchConcurrentAdvanceError({
|
||||||
`${taskId}: ff-merge of ${worktreeHeadSha.slice(0, 8)} into ${integrationBranch} failed (${ffMsg}); attempting non-ff merge with AI conflict resolution`,
|
integrationBranch,
|
||||||
);
|
expectedCurrentSha,
|
||||||
try {
|
observedCurrentSha: advanceResult.observedCurrentSha,
|
||||||
await execAsync(`git merge --no-edit ${quoteArg(worktreeHeadSha)}`, {
|
newSha: worktreeHeadSha,
|
||||||
cwd: projectRootDir,
|
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 120_000,
|
|
||||||
});
|
|
||||||
await (audit as any).git({
|
|
||||||
type: "merge:reuse-integration-branch-advanced",
|
|
||||||
target: integrationBranch,
|
|
||||||
metadata: { taskId, sha: worktreeHeadSha, via: "non-ff-merge", projectRootDir },
|
|
||||||
});
|
|
||||||
mergerLog.log(
|
|
||||||
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root (non-ff merge, no conflicts)`,
|
|
||||||
);
|
|
||||||
} catch (mergeErr: unknown) {
|
|
||||||
const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
|
||||||
const conflicted = await getConflictedFiles(projectRootDir).catch(() => [] as string[]);
|
|
||||||
if (conflicted.length === 0) {
|
|
||||||
await execAsync("git merge --abort", { cwd: projectRootDir, timeout: 30_000 }).catch(() => undefined);
|
|
||||||
throw new Error(
|
|
||||||
`Non-ff merge of ${worktreeHeadSha} into ${integrationBranch} failed without conflict markers: ${mergeMsg}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
mergerLog.log(
|
|
||||||
`${taskId}: ${conflicted.length} conflict(s) applying squash to ${integrationBranch} — invoking AI conflict resolution`,
|
|
||||||
);
|
|
||||||
const conflictAssignedAgentId = task.assignedAgentId?.trim();
|
|
||||||
const conflictAgentStoreWithGetAgent = options.agentStore
|
|
||||||
&& typeof (options.agentStore as { getAgent?: unknown }).getAgent === "function"
|
|
||||||
? options.agentStore
|
|
||||||
: null;
|
|
||||||
const conflictAssignedAgent = conflictAssignedAgentId && conflictAgentStoreWithGetAgent
|
|
||||||
? await (conflictAgentStoreWithGetAgent as any).getAgent(conflictAssignedAgentId).catch(() => null)
|
|
||||||
: null;
|
|
||||||
const conflictRuntimeHint = extractRuntimeHint(conflictAssignedAgent?.runtimeConfig);
|
|
||||||
await resolveComplexRebaseConflictsWithAi(
|
|
||||||
store,
|
|
||||||
projectRootDir,
|
|
||||||
taskId,
|
taskId,
|
||||||
settings,
|
|
||||||
conflicted,
|
|
||||||
{
|
|
||||||
onAgentText: options.onAgentText,
|
|
||||||
signal: options.signal,
|
|
||||||
runtimeHint: conflictRuntimeHint,
|
|
||||||
assignedAgentRuntimeConfig: conflictAssignedAgent?.runtimeConfig,
|
|
||||||
onSession: options.onSession,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const stillConflicted = await getConflictedFiles(projectRootDir).catch(() => [] as string[]);
|
|
||||||
if (stillConflicted.length > 0) {
|
|
||||||
await execAsync("git merge --abort", { cwd: projectRootDir, timeout: 30_000 }).catch(() => undefined);
|
|
||||||
throw new Error(
|
|
||||||
`AI conflict resolution left ${stillConflicted.length} unresolved file(s) when applying squash to ${integrationBranch}: ${stillConflicted.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await execAsync(`git commit --no-edit`, {
|
|
||||||
cwd: projectRootDir,
|
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
});
|
||||||
await (audit as any).git({
|
|
||||||
type: "merge:reuse-integration-branch-advanced",
|
|
||||||
target: integrationBranch,
|
|
||||||
metadata: {
|
|
||||||
taskId,
|
|
||||||
sha: worktreeHeadSha,
|
|
||||||
via: "non-ff-merge-ai-resolved",
|
|
||||||
projectRootDir,
|
|
||||||
conflictedFiles: conflicted,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mergerLog.log(
|
|
||||||
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root after AI conflict resolution (${conflicted.length} file(s))`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
throw new Error(`Failed to advance ${integrationBranch} via update-ref: ${advanceResult.diagnostic}`);
|
||||||
}
|
}
|
||||||
|
mergerLog.log(
|
||||||
|
`${taskId}: ${integrationBranch} advanced to ${worktreeHeadSha.slice(0, 8)} via update-ref; your checked-out worktree at ${projectRootDir} is now behind`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (advErr: unknown) {
|
} catch (advErr: unknown) {
|
||||||
const advMsg = advErr instanceof Error ? advErr.message : String(advErr);
|
const advMsg = advErr instanceof Error ? advErr.message : String(advErr);
|
||||||
mergerLog.error(
|
mergerLog.error(
|
||||||
`${taskId}: failed to apply squash to ${mergeTarget.branch} in project root: ${advMsg}`,
|
`${taskId}: failed to advance ${mergeTarget.branch} via update-ref: ${advMsg}`,
|
||||||
);
|
);
|
||||||
await (audit as any).git({
|
|
||||||
type: "merge:reuse-integration-branch-advance-failed",
|
|
||||||
target: mergeTarget.branch,
|
|
||||||
metadata: { taskId, error: advMsg, projectRootDir },
|
|
||||||
});
|
|
||||||
// Abort: leaving reuseTaskWorktreeMerge=true would cause the subsequent
|
// Abort: leaving reuseTaskWorktreeMerge=true would cause the subsequent
|
||||||
// push step to operate on projectRootDir where the squash was never
|
// push step to operate on projectRootDir where the target ref was never
|
||||||
// applied, shipping the pre-merge ref. Mark the reuse-merge as failed
|
// advanced, shipping the pre-merge ref. Mark the reuse-merge as failed
|
||||||
// and surface the error so the merge can be retried cleanly.
|
// and surface the error so the merge can be retried cleanly.
|
||||||
reuseTaskWorktreeMerge = false;
|
reuseTaskWorktreeMerge = false;
|
||||||
throw new Error(
|
throw advErr;
|
||||||
`Failed to advance ${mergeTarget.branch} in project root after reuse-task-worktree squash: ${advMsg}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9170,9 +9096,11 @@ export async function aiMergeTask(
|
|||||||
: null;
|
: null;
|
||||||
const pushRuntimeHint = extractRuntimeHint(pushAssignedAgent?.runtimeConfig);
|
const pushRuntimeHint = extractRuntimeHint(pushAssignedAgent?.runtimeConfig);
|
||||||
// In reuse-task-worktree mode, rootDir is the task worktree with a
|
// In reuse-task-worktree mode, rootDir is the task worktree with a
|
||||||
// detached HEAD; step 5c already advanced the project root's
|
// detached HEAD; step 5c advanced refs/heads/<integration-branch> via
|
||||||
// integration branch to the new squash commit, so push from
|
// `git update-ref` (FN-5350). The shared ref now points at the new squash
|
||||||
// projectRootDir where the branch is checked out and at the new tip.
|
// commit, so pushing from projectRootDir (where the branch is checked out,
|
||||||
|
// but the working tree may be dirty and is NOT touched by us) sends the
|
||||||
|
// new tip to the remote.
|
||||||
const pushRootDir = reuseTaskWorktreeMerge ? projectRootDir : rootDir;
|
const pushRootDir = reuseTaskWorktreeMerge ? projectRootDir : rootDir;
|
||||||
const pushResult = await pushToRemoteAfterMerge(store, pushRootDir, taskId, settings, {
|
const pushResult = await pushToRemoteAfterMerge(store, pushRootDir, taskId, settings, {
|
||||||
onAgentText: options.onAgentText,
|
onAgentText: options.onAgentText,
|
||||||
@@ -9342,7 +9270,11 @@ export async function aiMergeTask(
|
|||||||
* HEAD when origin is strictly ahead. Returns silently on any failure
|
* HEAD when origin is strictly ahead. Returns silently on any failure
|
||||||
* (no remote configured, network down, divergent local commits, etc.).
|
* (no remote configured, network down, divergent local commits, etc.).
|
||||||
* Only called for the smart strategies, which want to avoid resolving a
|
* Only called for the smart strategies, which want to avoid resolving a
|
||||||
* conflict against a stale local base. */
|
* conflict against a stale local base.
|
||||||
|
*
|
||||||
|
* NOTE: This is NOT FN-5350's integration-branch ref advance path. FN-5350
|
||||||
|
* advances refs/heads/<integration-branch> via compare-and-swap `git update-ref`.
|
||||||
|
*/
|
||||||
async function tryFastForwardFromOrigin(
|
async function tryFastForwardFromOrigin(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export type GitMutationType =
|
|||||||
| "merge:reuse-fallback-reused-existing-registration"
|
| "merge:reuse-fallback-reused-existing-registration"
|
||||||
| "merge:reuse-worktree-fresh-acquire"
|
| "merge:reuse-worktree-fresh-acquire"
|
||||||
| "merge:reuse-worktree-fresh-acquired"
|
| "merge:reuse-worktree-fresh-acquired"
|
||||||
|
| "merge:reuse-integration-branch-advanced"
|
||||||
|
| "merge:reuse-integration-branch-advance-failed"
|
||||||
| "merge:audit-failure"
|
| "merge:audit-failure"
|
||||||
| "branch:auto-reclaim"
|
| "branch:auto-reclaim"
|
||||||
| "branch:auto-canonicalize-case"
|
| "branch:auto-canonicalize-case"
|
||||||
|
|||||||
Reference in New Issue
Block a user