feat(FN-1612): merge fusion/fn-1612

This commit is contained in:
gsxdsm
2026-04-12 12:03:08 -07:00
parent e82736a171
commit ce82e94153
2 changed files with 163 additions and 99 deletions

View File

@@ -1,8 +1,43 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
// Store for mock results - shared between callback and promisified paths
let mockResults: (string | Error)[] = [];
let resultIndex = 0;
const execCalls: [string, object | undefined][] = [];
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn: typeof vi.fn = vi.fn((cmd: string, opts: object | undefined, cb: (err: Error | null, stdout: string, stderr: string) => void) => {
// Track the call for assertion purposes
execCalls.push([cmd, opts]);
const callback = typeof opts === "function" ? opts : cb;
// promisify path - callback is undefined
if (callback === undefined) {
return; // promisify.custom handles the Promise
}
try {
const result = mockResults[resultIndex++] || "";
const stdout = result instanceof Error ? "" : result.toString();
callback(null, stdout, "");
} catch (err) {
callback(err as Error, "", "");
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: string, opts?: object) => {
// Track the call for assertion purposes
execCalls.push([cmd, opts]);
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const result = mockResults[resultIndex++] || "";
if (result instanceof Error) {
reject(result);
} else {
resolve({ stdout: result.toString(), stderr: "" });
}
});
};
return { exec: execFn, execSync: vi.fn() };
});
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(() => ({
@@ -15,26 +50,34 @@ vi.mock("../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { execSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { resolveProject } from "../project-context.js";
import {
isGitRepo,
getGitStatus,
getDirtyFileCount,
isValidBranchName,
fetchGitRemote,
pullGitBranch,
pushGitBranch,
runGitStatus,
runGitFetch,
runGitPull,
runGitPush,
} from "./git.js";
const mockExecSync = vi.mocked(execSync);
const mockCreateInterface = vi.mocked(createInterface);
// Helper to set up sequential mock results
function mockNextResult(result: string) {
mockResults.push(result);
}
// Helper to check if exec was called with specific command
function wasExecCalled(cmd: string): boolean {
return execCalls.some(([c]) => c === cmd);
}
// Helper to get last exec call
function getLastExecCall(): [string, object | undefined] | undefined {
return execCalls[execCalls.length - 1];
}
describe("git commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
@@ -42,6 +85,9 @@ describe("git commands", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResults = [];
resultIndex = 0;
execCalls.length = 0;
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
@@ -52,7 +98,7 @@ describe("git commands", () => {
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: {} as any,
store: {} as ReturnType<typeof vi.fn>,
});
});
@@ -62,67 +108,76 @@ describe("git commands", () => {
exitSpy.mockRestore();
});
it("core helpers work", () => {
mockExecSync.mockReturnValueOnce(".git");
expect(isGitRepo()).toBe(true);
it("core helpers work", async () => {
mockNextResult(".git");
expect(await isGitRepo()).toBe(true);
expect(isValidBranchName("main")).toBe(true);
expect(isValidBranchName("--bad")).toBe(false);
});
it("runGitStatus uses resolved project path", async () => {
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce(" M file.ts\n")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce(" M file.ts\n");
// isGitRepo, branch, commit, status, rev-list, dirty count
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult(" M file.ts\n");
mockNextResult("0\t0\n");
mockNextResult(" M file.ts\n");
await runGitStatus("demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockExecSync).toHaveBeenCalledWith("git status --porcelain", expect.objectContaining({ cwd: "/projects/demo" }));
expect(wasExecCalled("git status --porcelain")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("runGitStatus without project uses shared resolution flow", async () => {
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n");
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
await runGitStatus();
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", expect.objectContaining({ cwd: "/projects/demo" }));
expect(wasExecCalled("git rev-parse --git-dir")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("runGitStatus without project falls back to current working directory when resolution fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
vi.mocked(resolveProject).mockRejectedValueOnce(new Error("No fusion project found"));
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n");
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
await runGitStatus();
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", expect.objectContaining({ cwd: "/local/project" }));
expect(wasExecCalled("git rev-parse --git-dir")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/local/project" });
cwdSpy.mockRestore();
});
it("runGitFetch uses resolved project path", async () => {
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("Fetch completed");
mockNextResult(".git");
mockNextResult("Fetch completed");
await runGitFetch("origin", "demo-project");
expect(mockExecSync).toHaveBeenCalledWith("git fetch origin", expect.objectContaining({ cwd: "/projects/demo" }));
expect(wasExecCalled("git fetch origin")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("propagates project resolution errors for git commands", async () => {
@@ -133,35 +188,39 @@ describe("git commands", () => {
it("runGitPull uses resolved project path", async () => {
const question = vi.fn().mockResolvedValue("y");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as any);
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce("Already up to date.")
.mockReturnValueOnce("Already up to date.");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as ReturnType<typeof createInterface>);
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
mockNextResult("Already up to date.");
mockNextResult("Already up to date.");
await runGitPull({ projectName: "demo-project" });
expect(mockExecSync).toHaveBeenCalledWith("git pull", expect.objectContaining({ cwd: "/projects/demo" }));
expect(wasExecCalled("git pull")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("runGitPush uses resolved project path", async () => {
const question = vi.fn().mockResolvedValue("y");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as any);
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as ReturnType<typeof createInterface>);
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
mockNextResult("");
mockNextResult("");
await runGitPush({ projectName: "demo-project" });
expect(mockExecSync).toHaveBeenCalledWith("git push", expect.objectContaining({ cwd: "/projects/demo" }));
expect(wasExecCalled("git push")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
});

View File

@@ -1,4 +1,7 @@
import { execSync } from "node:child_process";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { createInterface } from "node:readline/promises";
import { resolveProject } from "../project-context.js";
@@ -49,9 +52,9 @@ export type GitPushResult = {
/**
* Check if a directory is a git repository.
*/
export function isGitRepo(cwd: string = process.cwd()): boolean {
export async function isGitRepo(cwd: string = process.cwd()): Promise<boolean> {
try {
execSync("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000, cwd });
await execAsync("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000, cwd });
return true;
} catch {
return false;
@@ -85,25 +88,26 @@ export function isValidBranchName(name: string): boolean {
* Get the current git status including branch, commit hash, and dirty state.
* Returns structured data for CLI display.
*/
export function getGitStatus(cwd: string = process.cwd()): GitStatus | null {
export async function getGitStatus(cwd: string = process.cwd()): Promise<GitStatus | null> {
try {
// Get current branch (empty string means detached HEAD)
const branchOutput = execSync("git branch --show-current", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const branch = branchOutput || "HEAD detached";
const { stdout: branchOutput } = await execAsync("git branch --show-current", { encoding: "utf-8", timeout: 5000, cwd });
const branch = branchOutput.trim() || "HEAD detached";
// Get current commit hash (short)
const commit = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const { stdout: commitOut } = await execAsync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000, cwd });
const commit = commitOut.trim();
// Check if working directory is dirty
const statusOutput = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const isDirty = statusOutput.length > 0;
const { stdout: statusOutput } = await execAsync("git status --porcelain", { encoding: "utf-8", timeout: 5000, cwd });
const isDirty = statusOutput.trim().length > 0;
// Get ahead/behind counts from upstream
let ahead = 0;
let behind = 0;
try {
const revListOutput = execSync("git rev-list --left-right --count HEAD...@{u}", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const match = revListOutput.match(/(\d+)\s+(\d+)/);
const { stdout: revListOutput } = await execAsync("git rev-list --left-right --count HEAD...@{u}", { encoding: "utf-8", timeout: 5000, cwd });
const match = revListOutput.trim().match(/(\d+)\s+(\d+)/);
if (match) {
ahead = parseInt(match[1], 10);
behind = parseInt(match[2], 10);
@@ -121,9 +125,10 @@ export function getGitStatus(cwd: string = process.cwd()): GitStatus | null {
/**
* Count dirty files by parsing git status output.
*/
export function getDirtyFileCount(cwd: string = process.cwd()): { added: number; modified: number; deleted: number } {
export async function getDirtyFileCount(cwd: string = process.cwd()): Promise<{ added: number; modified: number; deleted: number }> {
try {
const output = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const { stdout } = await execAsync("git status --porcelain", { encoding: "utf-8", timeout: 5000, cwd });
const output = stdout.trim();
if (!output) return { added: 0, modified: 0, deleted: 0 };
const lines = output.split("\n").filter(Boolean);
@@ -148,13 +153,13 @@ export function getDirtyFileCount(cwd: string = process.cwd()): { added: number;
/**
* Fetch from origin or specified remote.
*/
export function fetchGitRemote(remote: string = "origin", cwd: string = process.cwd()): GitFetchResult {
export async function fetchGitRemote(remote: string = "origin", cwd: string = process.cwd()): Promise<GitFetchResult> {
if (!isValidBranchName(remote)) {
throw new Error("Invalid remote name");
}
try {
const output = execSync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000, cwd });
return { fetched: true, message: output.trim() || "Fetch completed" };
const { stdout } = await execAsync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000, cwd });
return { fetched: true, message: stdout.trim() || "Fetch completed" };
} catch (err: any) {
const message = err.message || String(err);
if (message.includes("Could not resolve host") || message.includes("Connection refused")) {
@@ -168,10 +173,10 @@ export function fetchGitRemote(remote: string = "origin", cwd: string = process.
/**
* Pull the current branch.
*/
export function pullGitBranch(cwd: string = process.cwd()): GitPullResult {
export async function pullGitBranch(cwd: string = process.cwd()): Promise<GitPullResult> {
try {
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd });
return { success: true, message: output.trim() };
const { stdout } = await execAsync("git pull", { encoding: "utf-8", timeout: 30000, cwd });
return { success: true, message: stdout.trim() };
} catch (err: any) {
const message = err.message || String(err);
if (message.includes("CONFLICT") || message.includes("Merge conflict")) {
@@ -184,10 +189,10 @@ export function pullGitBranch(cwd: string = process.cwd()): GitPullResult {
/**
* Push the current branch.
*/
export function pushGitBranch(cwd: string = process.cwd()): GitPushResult {
export async function pushGitBranch(cwd: string = process.cwd()): Promise<GitPushResult> {
try {
const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd });
return { success: true, message: output.trim() || "Push completed" };
const { stdout } = await execAsync("git push", { encoding: "utf-8", timeout: 30000, cwd });
return { success: true, message: stdout.trim() || "Push completed" };
} catch (err: any) {
const message = err.message || String(err);
if (message.includes("rejected") || message.includes("non-fast-forward")) {
@@ -209,12 +214,12 @@ export async function runGitStatus(projectName?: string): Promise<void> {
const projectPath = await resolveGitCwd(projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
if (!(await isGitRepo(projectPath))) {
console.error("Error: Not a git repository");
process.exit(1);
}
const status = getGitStatus(projectPath);
const status = await getGitStatus(projectPath);
if (!status) {
console.error("Error: Failed to get git status");
process.exit(1);
@@ -226,7 +231,7 @@ export async function runGitStatus(projectName?: string): Promise<void> {
// Status line
if (status.isDirty) {
const counts = getDirtyFileCount(projectPath);
const counts = await getDirtyFileCount(projectPath);
const parts: string[] = [];
if (counts.added) parts.push(`+${counts.added}`);
if (counts.modified) parts.push(`~${counts.modified}`);
@@ -260,7 +265,7 @@ export async function runGitFetch(remote?: string, projectName?: string): Promis
const projectPath = await resolveGitCwd(projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
if (!(await isGitRepo(projectPath))) {
console.error("Error: Not a git repository");
process.exit(1);
}
@@ -272,7 +277,7 @@ export async function runGitFetch(remote?: string, projectName?: string): Promis
}
try {
execSync(`git fetch ${targetRemote}`, { encoding: "utf-8", timeout: 30000, cwd: projectPath });
await execAsync(`git fetch ${targetRemote}`, { encoding: "utf-8", timeout: 30000, cwd: projectPath });
console.log();
console.log(` ✓ Fetched from ${targetRemote}`);
console.log();
@@ -291,13 +296,13 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?:
const projectPath = await resolveGitCwd(options.projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
if (!(await isGitRepo(projectPath))) {
console.error("Error: Not a git repository");
process.exit(1);
}
// Check for dirty state
const status = getGitStatus(projectPath);
const status = await getGitStatus(projectPath);
if (!status) {
console.error("Error: Failed to get git status");
process.exit(1);
@@ -308,7 +313,7 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?:
console.log();
console.log(" ⚠ Warning: You have uncommitted changes.");
console.log(` Branch: ${status.branch}`);
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(" Continue with pull? [y/N] ");
rl.close();
@@ -321,11 +326,11 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?:
}
try {
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
const { stdout } = await execAsync("git pull", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
console.log();
console.log(` ✓ Pulled latest changes for ${status.branch}`);
if (output.trim() && output.trim() !== "Already up to date.") {
console.log(` ${output.trim()}`);
if (stdout.trim() && stdout.trim() !== "Already up to date.") {
console.log(` ${stdout.trim()}`);
}
console.log();
} catch (err: any) {
@@ -348,13 +353,13 @@ export async function runGitPush(options: { skipConfirm?: boolean; projectName?:
const projectPath = await resolveGitCwd(options.projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
if (!(await isGitRepo(projectPath))) {
console.error("Error: Not a git repository");
process.exit(1);
}
// Get current branch
const status = getGitStatus(projectPath);
const status = await getGitStatus(projectPath);
if (!status) {
console.error("Error: Failed to get git status");
process.exit(1);
@@ -367,7 +372,7 @@ export async function runGitPush(options: { skipConfirm?: boolean; projectName?:
// Check for upstream
try {
execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { encoding: "utf-8", timeout: 5000, cwd: projectPath });
await execAsync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { encoding: "utf-8", timeout: 5000, cwd: projectPath });
} catch {
console.error("Error: No upstream configured for current branch");
console.error(` Run: git push -u origin ${status.branch}`);
@@ -389,11 +394,11 @@ export async function runGitPush(options: { skipConfirm?: boolean; projectName?:
}
try {
const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
const { stdout } = await execAsync("git push", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
console.log();
console.log(` ✓ Pushed ${status.branch} to origin`);
if (output.trim()) {
console.log(` ${output.trim()}`);
if (stdout.trim()) {
console.log(` ${stdout.trim()}`);
}
console.log();
} catch (err: any) {