feat(FN-4222): complete Step 2 — extend git ops for finalize
Fusion-Task-Id: FN-4222 Fusion-Task-Lineage: 7de6b18c-36d5-49e8-bbd7-084707e88d6a
This commit is contained in:
57
packages/engine/src/__tests__/finalize-git-ops.test.ts
Normal file
57
packages/engine/src/__tests__/finalize-git-ops.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultGitOps } from "../experiment/git-ops.js";
|
||||
|
||||
function createRepo() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "finalize-git-ops-"));
|
||||
execSync("git init", { cwd: dir });
|
||||
execSync("git config user.email test@example.com", { cwd: dir });
|
||||
execSync("git config user.name Test", { cwd: dir });
|
||||
writeFileSync(join(dir, "file.txt"), "one\n");
|
||||
execSync("git add file.txt", { cwd: dir });
|
||||
execSync('git commit -m "first"', { cwd: dir });
|
||||
writeFileSync(join(dir, "file.txt"), "two\n");
|
||||
execSync("git add file.txt", { cwd: dir });
|
||||
execSync('git commit -m "second"', { cwd: dir });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("finalize git ops", () => {
|
||||
it("supports mergeBase/branch/checkout/currentBranch/deleteBranch/cherryPick", async () => {
|
||||
const cwd = createRepo();
|
||||
try {
|
||||
const git = defaultGitOps(cwd);
|
||||
const head = await git.head();
|
||||
const initialBranch = (await git.currentBranch()) ?? "main";
|
||||
await git.createBranch("feature/a", head);
|
||||
expect(await git.branchExists("feature/a")).toBe(true);
|
||||
|
||||
await git.checkout("feature/a");
|
||||
expect(await git.currentBranch()).toBe("feature/a");
|
||||
|
||||
writeFileSync(join(cwd, "file.txt"), "feature\n");
|
||||
await git.add(["file.txt"]);
|
||||
const featureCommit = await git.commit("feature change");
|
||||
|
||||
await git.checkout(initialBranch);
|
||||
writeFileSync(join(cwd, "main.txt"), "main\n");
|
||||
await git.add(["main.txt"]);
|
||||
await git.commit("main change");
|
||||
|
||||
const mergeBase = await git.mergeBase(initialBranch, "feature/a");
|
||||
expect(mergeBase).toBe(head);
|
||||
|
||||
await git.checkout(initialBranch);
|
||||
await git.cherryPick(featureCommit);
|
||||
expect((await git.statusPorcelain()).trim()).toBe("");
|
||||
|
||||
await git.deleteBranch("feature/a", { force: true });
|
||||
expect(await git.branchExists("feature/a")).toBe(false);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
ExperimentFinalizeBranchExistsError,
|
||||
ExperimentFinalizeCherryPickConflictError,
|
||||
ExperimentFinalizeMergeBaseError,
|
||||
} from "./finalize-types.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const GIT_TIMEOUT_MS = 30_000;
|
||||
@@ -13,6 +18,13 @@ export interface GitOps {
|
||||
stashPush(message: string): Promise<string | null>;
|
||||
stashPop(ref: string): Promise<void>;
|
||||
statusPorcelain(): Promise<string>;
|
||||
mergeBase(refA: string, refB: string): Promise<string>;
|
||||
branchExists(name: string): Promise<boolean>;
|
||||
createBranch(name: string, startPoint: string): Promise<void>;
|
||||
cherryPick(commit: string): Promise<void>;
|
||||
checkout(ref: string): Promise<void>;
|
||||
currentBranch(): Promise<string | null>;
|
||||
deleteBranch(name: string, opts?: { force?: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]): Promise<string> {
|
||||
@@ -62,5 +74,58 @@ export function defaultGitOps(cwd: string): GitOps {
|
||||
async statusPorcelain() {
|
||||
return await runGit(cwd, ["status", "--porcelain"]);
|
||||
},
|
||||
async mergeBase(refA: string, refB: string) {
|
||||
try {
|
||||
return await runGit(cwd, ["merge-base", refA, refB]);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new ExperimentFinalizeMergeBaseError(`Unable to resolve merge-base for ${refA} and ${refB}: ${err.message}`);
|
||||
}
|
||||
},
|
||||
async branchExists(name: string) {
|
||||
try {
|
||||
await runGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async createBranch(name: string, startPoint: string) {
|
||||
const exists = await this.branchExists(name);
|
||||
if (exists) {
|
||||
throw new ExperimentFinalizeBranchExistsError(`Branch already exists: ${name}`);
|
||||
}
|
||||
await runGit(cwd, ["branch", name, startPoint]);
|
||||
},
|
||||
async cherryPick(commit: string) {
|
||||
try {
|
||||
await runGit(cwd, ["cherry-pick", commit]);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
try {
|
||||
await runGit(cwd, ["cherry-pick", "--abort"]);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
throw new ExperimentFinalizeCherryPickConflictError(`Cherry-pick failed for ${commit}`, {
|
||||
groupId: "unknown",
|
||||
commit,
|
||||
stderr: err.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
async checkout(ref: string) {
|
||||
await runGit(cwd, ["checkout", ref]);
|
||||
},
|
||||
async currentBranch() {
|
||||
try {
|
||||
return await runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async deleteBranch(name: string, opts?: { force?: boolean }) {
|
||||
await runGit(cwd, ["branch", opts?.force ? "-D" : "-d", name]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user