diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 5f4e7549ca7..61804e6f95f 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -41,12 +41,14 @@ async function makeFakeGitRepo( describe("git commit resolution", () => { const originalCwd = process.cwd(); - afterEach(() => { + afterEach(async () => { process.chdir(originalCwd); vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); vi.resetModules(); + const { __testing } = await import("./git-commit.js"); + __testing.clearCachedGitCommits(); }); it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { @@ -85,85 +87,64 @@ describe("git commit resolution", () => { encoding: "utf-8", }).trim(); - vi.doMock("node:module", () => ({ - createRequire: () => { - return (specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }; - }, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; - expect(resolveCommitHash({ moduleUrl: entryModuleUrl, env: {} })).toBe(repoHead); - - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + moduleUrl: entryModuleUrl, + env: {}, + readers: { + readBuildInfoCommit: () => "deadbee", + }, + }), + ).toBe(repoHead); }); it("caches build-info fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-build-info-cache"); - const moduleRequire = vi.fn((specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }); - - vi.doMock("node:module", () => ({ - createRequire: () => moduleRequire, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readBuildInfoCommit = vi.fn(() => "deadbee"); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); - const firstCallRequires = moduleRequire.mock.calls.length; + expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe( + "deadbee", + ); + const firstCallRequires = readBuildInfoCommit.mock.calls.length; expect(firstCallRequires).toBeGreaterThan(0); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); - expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); - - vi.doUnmock("node:module"); + expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe( + "deadbee", + ); + expect(readBuildInfoCommit.mock.calls.length).toBe(firstCallRequires); }); it("caches package.json fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-package-json-cache"); - const moduleRequire = vi.fn((specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - } - if (specifier === "../../package.json") { - return { gitHead: "badc0ffee0ddf00d" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }); - - vi.doMock("node:module", () => ({ - createRequire: () => moduleRequire, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readPackageJsonCommit = vi.fn(() => "badc0ff"); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); - const firstCallRequires = moduleRequire.mock.calls.length; + expect( + resolveCommitHash({ + cwd: temp, + env: {}, + readers: { + readBuildInfoCommit: () => null, + readPackageJsonCommit, + }, + }), + ).toBe("badc0ff"); + const firstCallRequires = readPackageJsonCommit.mock.calls.length; expect(firstCallRequires).toBeGreaterThan(0); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); - expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); - - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + cwd: temp, + env: {}, + readers: { + readBuildInfoCommit: () => null, + readPackageJsonCommit, + }, + }), + ).toBe("badc0ff"); + expect(readPackageJsonCommit.mock.calls.length).toBe(firstCallRequires); }); it("treats invalid moduleUrl inputs as a fallback hint instead of throwing", async () => { @@ -204,28 +185,19 @@ describe("git commit resolution", () => { ); const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; - vi.doMock("node:module", () => ({ - createRequire: () => { - return (specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - return { commit: "feedfacefeedfacefeedfacefeedfacefeedface" }; - } - if (specifier === "../../package.json") { - return { name: "openclaw", version: "2026.3.8", gitHead: "badc0ffee0ddf00d" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }; - }, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ moduleUrl, cwd: packageRoot, env: {} })).toBe("feedfac"); - - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + moduleUrl, + cwd: packageRoot, + env: {}, + readers: { + readBuildInfoCommit: () => "feedfac", + readPackageJsonCommit: () => "badc0ff", + }, + }), + ).toBe("feedfac"); }); it("caches git lookups per resolved search directory", async () => { @@ -253,27 +225,14 @@ describe("git commit resolution", () => { head: "not-a-commit\n", }); - const actualFs = await vi.importActual("node:fs"); - const readFileSyncSpy = vi.fn(actualFs.readFileSync); - vi.doMock("node:fs", () => ({ - ...actualFs, - default: { - ...actualFs, - readFileSync: readFileSyncSpy, - }, - readFileSync: readFileSyncSpy, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readGitCommit = vi.fn(() => null); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - const firstCallReads = readFileSyncSpy.mock.calls.length; + expect(resolveCommitHash({ cwd: repoRoot, env: {}, readers: { readGitCommit } })).toBeNull(); + const firstCallReads = readGitCommit.mock.calls.length; expect(firstCallReads).toBeGreaterThan(0); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - expect(readFileSyncSpy.mock.calls.length).toBe(firstCallReads); - - vi.doUnmock("node:fs"); + expect(resolveCommitHash({ cwd: repoRoot, env: {}, readers: { readGitCommit } })).toBeNull(); + expect(readGitCommit.mock.calls.length).toBe(firstCallReads); }); it("caches caught null fallback results per resolved search directory", async () => { @@ -282,49 +241,39 @@ describe("git commit resolution", () => { await makeFakeGitRepo(repoRoot, { head: "0123456789abcdef0123456789abcdef01234567\n", }); - const headPath = path.join(repoRoot, ".git", "HEAD"); - - const actualFs = await vi.importActual("node:fs"); - const readFileSyncSpy = vi.fn((filePath: string, ...args: unknown[]) => { - if (path.resolve(filePath) === path.resolve(headPath)) { - const error = Object.assign(new Error(`EACCES: permission denied, open '${filePath}'`), { - code: "EACCES", - }); - throw error; - } - return Reflect.apply(actualFs.readFileSync, actualFs, [filePath, ...args]); - }); - vi.doMock("node:fs", () => ({ - ...actualFs, - default: { - ...actualFs, - readFileSync: readFileSyncSpy, - }, - readFileSync: readFileSyncSpy, - })); - vi.doMock("node:module", () => ({ - createRequire: () => { - return (specifier: string) => { - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }; - }, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readGitCommit = vi.fn(() => { + const error = Object.assign(new Error(`EACCES: permission denied`), { + code: "EACCES", + }); + throw error; + }); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - const headReadCount = () => - readFileSyncSpy.mock.calls.filter(([filePath]) => path.resolve(filePath) === headPath).length; - const firstCallReads = headReadCount(); + expect( + resolveCommitHash({ + cwd: repoRoot, + env: {}, + readers: { + readGitCommit, + readBuildInfoCommit: () => null, + readPackageJsonCommit: () => null, + }, + }), + ).toBeNull(); + const firstCallReads = readGitCommit.mock.calls.length; expect(firstCallReads).toBe(2); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - expect(headReadCount()).toBe(firstCallReads); - - vi.doUnmock("node:fs"); - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + cwd: repoRoot, + env: {}, + readers: { + readGitCommit, + readBuildInfoCommit: () => null, + readPackageJsonCommit: () => null, + }, + }), + ).toBeNull(); + expect(readGitCommit.mock.calls.length).toBe(firstCallReads); }); it("formats env-provided commit strings consistently", async () => { diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts index 74e22156e1b..e413fc9fa9d 100644 --- a/src/infra/git-commit.ts +++ b/src/infra/git-commit.ts @@ -22,6 +22,12 @@ const formatCommit = (value?: string | null) => { const cachedGitCommitBySearchDir = new Map(); +export type CommitMetadataReaders = { + readGitCommit?: (searchDir: string, packageRoot: string | null) => string | null | undefined; + readBuildInfoCommit?: () => string | null; + readPackageJsonCommit?: () => string | null; +}; + function isMissingPathError(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -61,6 +67,10 @@ const cacheGitCommit = (searchDir: string, commit: string | null) => { return commit; }; +const clearCachedGitCommits = () => { + cachedGitCommitBySearchDir.clear(); +}; + const resolveGitLookupDepth = (searchDir: string, packageRoot: string | null) => { if (!packageRoot) { return undefined; @@ -176,9 +186,12 @@ export const resolveCommitHash = ( cwd?: string; env?: NodeJS.ProcessEnv; moduleUrl?: string; + readers?: CommitMetadataReaders; } = {}, ) => { const env = options.env ?? process.env; + const readers = options.readers ?? {}; + const readGitCommit = readers.readGitCommit ?? readCommitFromGit; const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); const normalized = formatCommit(envCommit); if (normalized) { @@ -193,24 +206,28 @@ export const resolveCommitHash = ( moduleUrl: options.moduleUrl, }); try { - const gitCommit = readCommitFromGit(searchDir, packageRoot); + const gitCommit = readGitCommit(searchDir, packageRoot); if (gitCommit !== undefined) { return cacheGitCommit(searchDir, gitCommit); } } catch { // Fall through to baked metadata for packaged installs that are not in a live checkout. } - const buildInfoCommit = readCommitFromBuildInfo(); + const buildInfoCommit = readers.readBuildInfoCommit?.() ?? readCommitFromBuildInfo(); if (buildInfoCommit) { return cacheGitCommit(searchDir, buildInfoCommit); } - const pkgCommit = readCommitFromPackageJson(); + const pkgCommit = readers.readPackageJsonCommit?.() ?? readCommitFromPackageJson(); if (pkgCommit) { return cacheGitCommit(searchDir, pkgCommit); } try { - return cacheGitCommit(searchDir, readCommitFromGit(searchDir, packageRoot) ?? null); + return cacheGitCommit(searchDir, readGitCommit(searchDir, packageRoot) ?? null); } catch { return cacheGitCommit(searchDir, null); } }; + +export const __testing = { + clearCachedGitCommits, +};