diff --git a/scripts/check-file-utils.ts b/scripts/check-file-utils.ts index 07e6b1c957cf..6df7f007eef2 100644 --- a/scripts/check-file-utils.ts +++ b/scripts/check-file-utils.ts @@ -14,6 +14,8 @@ export const REPO_SCAN_SKIPPED_DIR_NAMES: ReadonlySet = new Set([ "node_modules", "vendor", ]); +// Bound the Git lookup before falling back to direct filesystem traversal. +const GIT_LS_FILES_TIMEOUT_MS = 30_000; export function isCodeFile(filePath: string): boolean { if (filePath.endsWith(".d.ts")) { @@ -89,6 +91,8 @@ export function listRepoFilesSync( return execFileSync("git", ["-C", repoRoot, "ls-files", "--", ...roots], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + timeout: GIT_LS_FILES_TIMEOUT_MS, + killSignal: "SIGKILL", }) .split(/\r?\n/u) .filter(Boolean) diff --git a/test/scripts/check-file-utils.test.ts b/test/scripts/check-file-utils.test.ts index 17ff76fdd415..7342fd848159 100644 --- a/test/scripts/check-file-utils.test.ts +++ b/test/scripts/check-file-utils.test.ts @@ -1,15 +1,23 @@ // Check File Utils tests cover check file utils script behavior. import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { collectFilesSync, isCodeFile, + listRepoFilesSync, relativeToCwd, toPosixPath, } from "../../scripts/check-file-utils.js"; import { createScriptTestHarness } from "./test-helpers.js"; +const execFileSyncMock = vi.hoisted(() => vi.fn(() => "")); + +vi.mock("node:child_process", async (importOriginal) => { + const original = (await importOriginal()) as typeof import("node:child_process"); + return { ...original, execFileSync: execFileSyncMock }; +}); + const { createTempDir } = createScriptTestHarness(); describe("scripts/check-file-utils isCodeFile", () => { @@ -64,3 +72,45 @@ describe("scripts/check-file-utils relativeToCwd", () => { ); }); }); + +describe("scripts/check-file-utils listRepoFilesSync", () => { + afterEach(() => { + execFileSyncMock.mockReset(); + }); + + it("bounds git ls-files with a timeout and kill signal", () => { + execFileSyncMock.mockReturnValue("src/keep.ts\nsrc/skip.d.ts\n"); + + expect( + listRepoFilesSync("/fake/repo", { + includeFile: (filePath) => isCodeFile(filePath), + }), + ).toEqual(["src/keep.ts"]); + expect(execFileSyncMock).toHaveBeenCalledWith( + "git", + expect.arrayContaining(["-C", "/fake/repo", "ls-files", "--"]), + expect.objectContaining({ + timeout: 30_000, + killSignal: "SIGKILL", + }), + ); + }); + + it("falls back to filesystem traversal when git ls-files times out", () => { + const error: NodeJS.ErrnoException & { signal?: string } = new Error("Command timed out"); + error.code = "ETIMEDOUT"; + error.signal = "SIGKILL"; + execFileSyncMock.mockImplementation(() => { + throw error; + }); + const rootDir = createTempDir("openclaw-check-file-utils-fallback-"); + fs.mkdirSync(path.join(rootDir, "src"), { recursive: true }); + fs.writeFileSync(path.join(rootDir, "src", "keep.ts"), ""); + + expect( + listRepoFilesSync(rootDir, { + includeFile: (filePath) => filePath.endsWith(".ts"), + }), + ).toEqual(["src/keep.ts"]); + }); +});