test(FN-4474): add branch autocorrect unit coverage

Fusion-Task-Id: FN-4474
Fusion-Task-Lineage: 979782e1-c44d-4f35-aded-237bf33d0c9f
This commit is contained in:
Fusion
2026-05-14 08:34:38 -07:00
committed by gsxdsm
parent 653b88b092
commit 765838cc15
2 changed files with 138 additions and 11 deletions

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ExecException } from "node:child_process";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execMock = vi.fn();
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
Promise.resolve()
.then(() => execMock(cmd, typeof opts === "function" ? {} : (opts ?? {})))
.then((result) => callback?.(null, result?.stdout ?? "", result?.stderr ?? ""))
.catch((err) => {
const e = err as ExecException & { stdout?: string; stderr?: string };
callback?.(err, e.stdout ?? "", e.stderr ?? "");
});
});
execFn[promisify.custom] = (cmd: string, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
if (err) {
(err as Record<string, unknown>).stdout = stdout;
(err as Record<string, unknown>).stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { exec: execFn, __execMock: execMock };
});
import { attemptBranchAutocorrect } from "../branch-autocorrect.js";
import { __execMock as execMock } from "node:child_process";
const mockedExec = vi.mocked(execMock);
function execError(stderr: string): Error & { stderr: string } {
const error = new Error("exec failed") as Error & { stderr: string };
error.stderr = stderr;
return error;
}
describe("attemptBranchAutocorrect", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each([
{ observed: "", expected: "fusion/fn-1" },
{ observed: "fusion/fn-1", expected: "" },
{ observed: "fusion/fn-1", expected: "fusion/fn-1" },
])("returns invalid-input for bad branch names %#", async ({ observed, expected }) => {
const result = await attemptBranchAutocorrect({ worktreePath: "/tmp/wt", observedBranch: observed, expectedBranch: expected, rootDir: "/tmp" });
expect(result).toEqual({ status: "failed", reason: "invalid-input" });
expect(mockedExec).not.toHaveBeenCalled();
});
it("renames fresh branch", async () => {
mockedExec
.mockResolvedValueOnce({ stdout: "" }) // upstream check fails via rejection below, so first call should reject
.mockRejectedValueOnce(execError("no upstream"));
mockedExec.mockReset();
mockedExec
.mockRejectedValueOnce(execError("no upstream"))
.mockResolvedValueOnce({ stdout: "abc123\n" })
.mockResolvedValueOnce({ stdout: "lemon-sage\n" })
.mockResolvedValueOnce({ stdout: "" });
const result = await attemptBranchAutocorrect({ worktreePath: "/tmp/wt", observedBranch: "lemon-sage", expectedBranch: "fusion/fn-2", rootDir: "/tmp" });
expect(result).toEqual({ status: "renamed" });
expect(mockedExec.mock.calls.map((c) => c[0])).toContain("git branch -m 'lemon-sage' 'fusion/fn-2'");
});
it("falls back to checkout -B when branch has upstream", async () => {
mockedExec
.mockResolvedValueOnce({ stdout: "origin/lemon-sage\n" })
.mockResolvedValueOnce({ stdout: "" });
const result = await attemptBranchAutocorrect({ worktreePath: "/tmp/wt", observedBranch: "lemon-sage", expectedBranch: "fusion/fn-2", rootDir: "/tmp" });
expect(result).toEqual({ status: "checked-out" });
expect(mockedExec.mock.calls.map((c) => c[0])).toEqual([
"git rev-parse --abbrev-ref --symbolic-full-name 'lemon-sage'@{u}",
"git checkout -B 'fusion/fn-2'",
]);
});
it("falls back to checkout -B when branch sha is shared", async () => {
mockedExec
.mockRejectedValueOnce(execError("no upstream"))
.mockResolvedValueOnce({ stdout: "abc123\n" })
.mockResolvedValueOnce({ stdout: "lemon-sage\nmain\n" })
.mockResolvedValueOnce({ stdout: "" });
const result = await attemptBranchAutocorrect({ worktreePath: "/tmp/wt", observedBranch: "lemon-sage", expectedBranch: "fusion/fn-2", rootDir: "/tmp" });
expect(result).toEqual({ status: "checked-out" });
});
it("falls back to checkout -B when rename fails", async () => {
mockedExec
.mockRejectedValueOnce(execError("no upstream"))
.mockResolvedValueOnce({ stdout: "abc123\n" })
.mockResolvedValueOnce({ stdout: "lemon-sage\n" })
.mockRejectedValueOnce(execError("rename denied"))
.mockResolvedValueOnce({ stdout: "" });
const result = await attemptBranchAutocorrect({ worktreePath: "/tmp/wt", observedBranch: "lemon-sage", expectedBranch: "fusion/fn-2", rootDir: "/tmp" });
expect(result).toEqual({ status: "checked-out" });
});
it("returns failed when rename and checkout both fail", async () => {
mockedExec
.mockRejectedValueOnce(execError("no upstream"))
.mockResolvedValueOnce({ stdout: "abc123\n" })
.mockResolvedValueOnce({ stdout: "lemon-sage\n" })
.mockRejectedValueOnce(execError("rename denied"))
.mockRejectedValueOnce(execError("checkout denied"));
const result = await attemptBranchAutocorrect({ worktreePath: "/tmp/wt", observedBranch: "lemon-sage", expectedBranch: "fusion/fn-2", rootDir: "/tmp" });
expect(result).toEqual({ status: "failed", reason: "checkout denied" });
});
});

View File

@@ -56,19 +56,21 @@ export async function attemptBranchAutocorrect({
const observedArg = quoteShellArg(observed);
const expectedArg = quoteShellArg(expected);
const upstream = await runGit(`git rev-parse --abbrev-ref --symbolic-full-name ${observedArg}@{u}`, worktreePath);
const observedShaResult = await runGit(`git rev-parse ${observedArg}`, worktreePath);
let isFreshBranch = false;
if (!upstream.ok && observedShaResult.ok) {
const observedSha = observedShaResult.stdout.trim();
if (/^[0-9a-f]{4,64}$/i.test(observedSha)) {
const contains = await runGit(`git for-each-ref --format='%(refname:short)' --contains ${observedSha} refs/heads/`, worktreePath);
if (contains.ok) {
const refs = contains.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
isFreshBranch = refs.length === 1 && refs[0] === observed;
if (!upstream.ok) {
const observedShaResult = await runGit(`git rev-parse ${observedArg}`, worktreePath);
if (observedShaResult.ok) {
const observedSha = observedShaResult.stdout.trim();
if (/^[0-9a-f]{4,64}$/i.test(observedSha)) {
const contains = await runGit(`git for-each-ref --format='%(refname:short)' --contains ${observedSha} refs/heads/`, worktreePath);
if (contains.ok) {
const refs = contains.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
isFreshBranch = refs.length === 1 && refs[0] === observed;
}
}
}
}