feat(FN-4476): complete Step 2 — add patch-id unique commit helper
Fusion-Task-Id: FN-4476 Fusion-Task-Lineage: 8673c1d9-f47e-4d02-89e6-646040b9be32
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
||||
assertCleanBranchAtBase,
|
||||
inspectBranchConflict,
|
||||
listBranchRecoveryCandidates,
|
||||
listUniqueBranchCommits,
|
||||
} from "../branch-conflicts.js";
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
@@ -185,6 +186,92 @@ describe("branch-conflicts", () => {
|
||||
expect(result.error.message).toContain("Run branch recovery");
|
||||
});
|
||||
|
||||
it("lists zero unique commits when git cherry has no plus entries", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command.includes("git rev-parse --verify 'main^{commit}'")) {
|
||||
return Buffer.from("mainsha\n");
|
||||
}
|
||||
if (command === "git merge-base 'main' 'fusion/fn-4068'") {
|
||||
return Buffer.from("base123\n");
|
||||
}
|
||||
if (command === "git cherry 'main' 'fusion/fn-4068' 'base123'") {
|
||||
return Buffer.from("- abc111\n");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await listUniqueBranchCommits("/tmp/repo", "main", "fusion/fn-4068");
|
||||
|
||||
expect(result).toEqual({ commits: [], mainRef: "main", degraded: false });
|
||||
});
|
||||
|
||||
it("lists patch-id-unique commits from git cherry plus lines", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command.includes("git rev-parse --verify 'main^{commit}'")) {
|
||||
return Buffer.from("mainsha\n");
|
||||
}
|
||||
if (command === "git merge-base 'main' 'fusion/fn-4068'") {
|
||||
return Buffer.from("base123\n");
|
||||
}
|
||||
if (command === "git cherry 'main' 'fusion/fn-4068' 'base123'") {
|
||||
return Buffer.from("+ abc111\n+ def222\n");
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'abc111^{commit}'")) {
|
||||
return Buffer.from("abc111full\n");
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'def222^{commit}'")) {
|
||||
return Buffer.from("def222full\n");
|
||||
}
|
||||
if (command === "git log -1 --format=%s 'abc111'") {
|
||||
return Buffer.from("First unique\n");
|
||||
}
|
||||
if (command === "git log -1 --format=%s 'def222'") {
|
||||
return Buffer.from("Second unique\n");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await listUniqueBranchCommits("/tmp/repo", "main", "fusion/fn-4068");
|
||||
|
||||
expect(result).toEqual({
|
||||
commits: [
|
||||
{ sha: "abc111full", subject: "First unique" },
|
||||
{ sha: "def222full", subject: "Second unique" },
|
||||
],
|
||||
mainRef: "main",
|
||||
degraded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to rev-list stranded commits when git cherry fails", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command.includes("git rev-parse --verify 'main^{commit}'")) {
|
||||
return Buffer.from("mainsha\n");
|
||||
}
|
||||
if (command === "git merge-base 'main' 'fusion/fn-4068'") {
|
||||
return Buffer.from("base123\n");
|
||||
}
|
||||
if (command === "git cherry 'main' 'fusion/fn-4068' 'base123'") {
|
||||
throw new Error("cherry failed");
|
||||
}
|
||||
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\tFallback one\n");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await listUniqueBranchCommits("/tmp/repo", "main", "fusion/fn-4068");
|
||||
|
||||
expect(result).toEqual({
|
||||
commits: [{ sha: "aaa111", subject: "Fallback one" }],
|
||||
mainRef: "main",
|
||||
degraded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("assertCleanBranchAtBase passes when no foreign task commits exist", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
|
||||
@@ -104,6 +104,12 @@ export type BranchConflictInspectionResult =
|
||||
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
|
||||
| { kind: "live-foreign"; livePath: string; error: BranchConflictError };
|
||||
|
||||
interface UniqueBranchCommitListResult {
|
||||
commits: BranchConflictCommit[];
|
||||
mainRef: string;
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
export interface ListBranchRecoveryCandidatesInput {
|
||||
repoDir: string;
|
||||
branchName: string;
|
||||
@@ -148,6 +154,58 @@ async function listStrandedCommits(repoDir: string, startPoint: string, branchNa
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBranchComparisonRef(repoDir: string, startPoint: string, branchName: string): Promise<string> {
|
||||
try {
|
||||
await revParse(repoDir, startPoint);
|
||||
await runGit(repoDir, `git merge-base ${quoteShellArg(startPoint)} ${quoteShellArg(branchName)}`);
|
||||
return startPoint;
|
||||
} catch {
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUniqueBranchCommits(
|
||||
repoDir: string,
|
||||
startPoint: string,
|
||||
branchName: string,
|
||||
): Promise<UniqueBranchCommitListResult> {
|
||||
const mainRef = await resolveBranchComparisonRef(repoDir, startPoint, branchName);
|
||||
try {
|
||||
const comparisonBase = await runGit(repoDir, `git merge-base ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)}`);
|
||||
const cherryOutput = await runGit(
|
||||
repoDir,
|
||||
`git cherry ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)} ${quoteShellArg(comparisonBase)}`,
|
||||
);
|
||||
const plusTokens = cherryOutput
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("+ "))
|
||||
.map((line) => line.slice(2).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const commits: BranchConflictCommit[] = [];
|
||||
for (const token of plusTokens) {
|
||||
const [sha, subject] = await Promise.all([
|
||||
runGit(repoDir, `git rev-parse --verify ${quoteShellArg(`${token}^{commit}`)}`).catch(() => token),
|
||||
runGit(repoDir, `git log -1 --format=%s ${quoteShellArg(token)}`).catch(() => ""),
|
||||
]);
|
||||
commits.push({ sha, subject });
|
||||
}
|
||||
|
||||
return {
|
||||
commits,
|
||||
mainRef,
|
||||
degraded: false,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
commits: await listStrandedCommits(repoDir, mainRef, branchName),
|
||||
mainRef,
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorktreeBranchMap(repoDir: string): Promise<Map<string, string>> {
|
||||
const output = await runGit(repoDir, "git worktree list --porcelain");
|
||||
const map = new Map<string, string>();
|
||||
|
||||
Reference in New Issue
Block a user