fix(FN-1600): complete async conversion of hasRecoverableGitWork
- Convert hasRecoverableGitWork from sync execSync to async execAsync - Update recoverNoProgressNoTaskDoneFailures caller to await the method - Update self-healing tests to work with async method and execAsync mock - Add exec and promisify imports to self-healing.ts - Fix mock to include exec export for promisify compatibility
This commit is contained in:
@@ -1,8 +1,39 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
// Mock node modules
|
||||
// Route async `exec` through the `execSync` mock so existing tests that set up
|
||||
// mockedExecSync.mockImplementation for verification keep working unchanged.
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { promisify: utilPromisify } = await import("node:util");
|
||||
const execSyncFn = vi.fn();
|
||||
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "object" && opts !== null ? opts : {};
|
||||
try {
|
||||
const out = execSyncFn(cmd, { ...options, stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
|
||||
execFn[utilPromisify.custom] = (cmd: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
return { execSync: execSyncFn, exec: execFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
@@ -429,7 +460,7 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("treats dirty worktrees as recoverable git work", () => {
|
||||
it("treats dirty worktrees as recoverable git work", async () => {
|
||||
const task = {
|
||||
id: "FN-1473",
|
||||
worktree: "/tmp/test-project/.worktrees/fn-1473",
|
||||
@@ -443,7 +474,7 @@ describe("SelfHealingManager", () => {
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
expect((manager as any).hasRecoverableGitWork(task)).toBe(true);
|
||||
expect(await (manager as any).hasRecoverableGitWork(task)).toBe(true);
|
||||
mockedExecSync.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* by cleaning oldest idle worktrees when count exceeds 2× maxWorktrees.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getTaskMergeBlocker, type TaskStore, type Settings, type Task } from "@fusion/core";
|
||||
@@ -21,6 +22,7 @@ import { createLogger } from "./logger.js";
|
||||
import { scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
|
||||
const log = createLogger("self-healing");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface SelfHealingOptions {
|
||||
/** Project root directory (parent of .worktrees/) */
|
||||
@@ -708,7 +710,7 @@ export class SelfHealingManager {
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
try {
|
||||
if (this.hasRecoverableGitWork(task)) {
|
||||
if (await this.hasRecoverableGitWork(task)) {
|
||||
log.log(`${task.id} has recoverable git work — leaving in-progress for inspection`);
|
||||
continue;
|
||||
}
|
||||
@@ -739,16 +741,14 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private hasRecoverableGitWork(task: Task): boolean {
|
||||
private async hasRecoverableGitWork(task: Task): Promise<boolean> {
|
||||
if (task.worktree && existsSync(task.worktree)) {
|
||||
try {
|
||||
const status = execSync("git status --porcelain", {
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: task.worktree,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
}).trim();
|
||||
if (status.length > 0) return true;
|
||||
});
|
||||
if (status.trim().length > 0) return true;
|
||||
} catch {
|
||||
// If we cannot inspect an existing worktree, preserve it.
|
||||
return true;
|
||||
@@ -757,9 +757,8 @@ export class SelfHealingManager {
|
||||
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${branchName}"`, {
|
||||
await execAsync(`git rev-parse --verify "${branchName}"`, {
|
||||
cwd: this.options.rootDir,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
@@ -767,13 +766,11 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const uniqueCommits = execSync(`git rev-list --count HEAD.."${branchName}"`, {
|
||||
cwd: this.options.rootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
}).trim();
|
||||
return Number.parseInt(uniqueCommits, 10) > 0;
|
||||
const { stdout: uniqueCommits } = await execAsync(
|
||||
`git rev-list --count HEAD.."${branchName}"`,
|
||||
{ cwd: this.options.rootDir, timeout: 30_000 },
|
||||
);
|
||||
return Number.parseInt(uniqueCommits.trim(), 10) > 0;
|
||||
} catch {
|
||||
// If the branch exists but cannot be compared, preserve it.
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user