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
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(
|
||||
(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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user