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:
Fusion (runfusion.ai)
2026-05-21 13:55:51 -07:00
committed by gsxdsm
parent 109f969f06
commit caeb6a7e00
7 changed files with 531 additions and 119 deletions

View 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 });
}
});
});

View File

@@ -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 });
}
});
});

View File

@@ -78,9 +78,11 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
const advanced = audits.find(
(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 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 {
await fixture.cleanup();
}