import { execFileSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; async function makeTempDir(label: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); } async function makeFakeGitRepo( root: string, options: { head: string; refs?: Record; gitdir?: string; commondir?: string; }, ) { await fs.mkdir(root, { recursive: true }); const gitdir = options.gitdir ?? path.join(root, ".git"); if (options.gitdir) { await fs.writeFile(path.join(root, ".git"), `gitdir: ${options.gitdir}\n`, "utf-8"); } else { await fs.mkdir(gitdir, { recursive: true }); } await fs.mkdir(gitdir, { recursive: true }); await fs.writeFile(path.join(gitdir, "HEAD"), options.head, "utf-8"); if (options.commondir) { await fs.writeFile(path.join(gitdir, "commondir"), options.commondir, "utf-8"); } for (const [refPath, commit] of Object.entries(options.refs ?? {})) { const targetPath = path.join(gitdir, refPath); await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.writeFile(targetPath, `${commit}\n`, "utf-8"); } } describe("git commit resolution", () => { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); beforeEach(async () => { process.chdir(repoRoot); vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); vi.resetModules(); const { __testing } = await import("./git-commit.js"); __testing.clearCachedGitCommits(); }); afterEach(async () => { process.chdir(repoRoot); 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 () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", }).trim(); const temp = await makeTempDir("git-commit-cwd"); const otherRepo = path.join(temp, "other"); await fs.mkdir(otherRepo, { recursive: true }); execFileSync("git", ["init", "-q"], { cwd: otherRepo }); await fs.writeFile(path.join(otherRepo, "note.txt"), "x\n", "utf-8"); execFileSync("git", ["add", "note.txt"], { cwd: otherRepo }); execFileSync( "git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"], { cwd: otherRepo }, ); const otherHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: otherRepo, encoding: "utf-8", }).trim(); process.chdir(otherRepo); const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).toBe(repoHead); expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).not.toBe(otherHead); }); it("prefers live git metadata over stale build info in a real checkout", async () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", }).trim(); const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; 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 { resolveCommitHash } = await import("./git-commit.js"); const readBuildInfoCommit = vi.fn(() => "deadbee"); expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe( "deadbee", ); const firstCallRequires = readBuildInfoCommit.mock.calls.length; expect(firstCallRequires).toBeGreaterThan(0); 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 { resolveCommitHash } = await import("./git-commit.js"); const readPackageJsonCommit = vi.fn(() => "badc0ff"); 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: {}, 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 () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", }).trim(); const { resolveCommitHash } = await import("./git-commit.js"); expect(() => resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} }), ).not.toThrow(); expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} })).toBe( repoHead, ); }); it("does not walk out of the openclaw package into a host repo", async () => { const temp = await makeTempDir("git-commit-package-boundary"); const hostRepo = path.join(temp, "host"); await fs.mkdir(hostRepo, { recursive: true }); execFileSync("git", ["init", "-q"], { cwd: hostRepo }); await fs.writeFile(path.join(hostRepo, "host.txt"), "x\n", "utf-8"); execFileSync("git", ["add", "host.txt"], { cwd: hostRepo }); execFileSync( "git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"], { cwd: hostRepo }, ); const packageRoot = path.join(hostRepo, "node_modules", "openclaw"); await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); await fs.writeFile( path.join(packageRoot, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.3.8" }), "utf-8", ); const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; const { resolveCommitHash } = await import("./git-commit.js"); expect( resolveCommitHash({ moduleUrl, cwd: packageRoot, env: {}, readers: { readBuildInfoCommit: () => "feedfac", readPackageJsonCommit: () => "badc0ff", }, }), ).toBe("feedfac"); }); it("caches git lookups per resolved search directory", async () => { const temp = await makeTempDir("git-commit-cache"); const repoA = path.join(temp, "repo-a"); const repoB = path.join(temp, "repo-b"); await makeFakeGitRepo(repoA, { head: "0123456789abcdef0123456789abcdef01234567\n", }); await makeFakeGitRepo(repoB, { head: "89abcdef0123456789abcdef0123456789abcdef\n", }); const { resolveCommitHash } = await import("./git-commit.js"); expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); expect(resolveCommitHash({ cwd: repoB, env: {} })).toBe("89abcde"); expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); }); it("caches deterministic null results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-null-cache"); const repoRoot = path.join(temp, "repo"); await makeFakeGitRepo(repoRoot, { head: "not-a-commit\n", }); const { resolveCommitHash } = await import("./git-commit.js"); const readGitCommit = vi.fn(() => null); expect(resolveCommitHash({ cwd: repoRoot, env: {}, readers: { readGitCommit } })).toBeNull(); const firstCallReads = readGitCommit.mock.calls.length; expect(firstCallReads).toBeGreaterThan(0); 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 () => { const temp = await makeTempDir("git-commit-caught-null-cache"); const repoRoot = path.join(temp, "repo"); await makeFakeGitRepo(repoRoot, { head: "0123456789abcdef0123456789abcdef01234567\n", }); 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: {}, readers: { readGitCommit, readBuildInfoCommit: () => null, readPackageJsonCommit: () => null, }, }), ).toBeNull(); const firstCallReads = readGitCommit.mock.calls.length; expect(firstCallReads).toBe(2); 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 () => { const temp = await makeTempDir("git-commit-env"); const { resolveCommitHash } = await import("./git-commit.js"); expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "ABCDEF0123456789" } })).toBe( "abcdef0", ); expect( resolveCommitHash({ cwd: temp, env: { GIT_SHA: "commit abcdef0123456789 dirty" } }), ).toBe("abcdef0"); expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "not-a-sha" } })).toBeNull(); expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "" } })).toBeNull(); }); it("rejects unsafe HEAD refs and accepts valid refs", async () => { const temp = await makeTempDir("git-commit-refs"); const { resolveCommitHash } = await import("./git-commit.js"); const absoluteRepo = path.join(temp, "absolute"); await makeFakeGitRepo(absoluteRepo, { head: "ref: /tmp/evil\n" }); expect(resolveCommitHash({ cwd: absoluteRepo, env: {} })).toBeNull(); const traversalRepo = path.join(temp, "traversal"); await makeFakeGitRepo(traversalRepo, { head: "ref: refs/heads/../evil\n" }); expect(resolveCommitHash({ cwd: traversalRepo, env: {} })).toBeNull(); const invalidPrefixRepo = path.join(temp, "invalid-prefix"); await makeFakeGitRepo(invalidPrefixRepo, { head: "ref: heads/main\n" }); expect(resolveCommitHash({ cwd: invalidPrefixRepo, env: {} })).toBeNull(); const validRepo = path.join(temp, "valid"); await makeFakeGitRepo(validRepo, { head: "ref: refs/heads/main\n", refs: { "refs/heads/main": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }, }); expect(resolveCommitHash({ cwd: validRepo, env: {} })).toBe("aaaaaaa"); }); it("resolves refs from the git commondir in worktree layouts", async () => { const temp = await makeTempDir("git-commit-worktree"); const repoRoot = path.join(temp, "repo"); const worktreeGitDir = path.join(temp, "worktree-git"); const commonGitDir = path.join(temp, "common-git"); await fs.mkdir(commonGitDir, { recursive: true }); const refPath = path.join(commonGitDir, "refs", "heads", "main"); await fs.mkdir(path.dirname(refPath), { recursive: true }); await fs.writeFile(refPath, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n", "utf-8"); await makeFakeGitRepo(repoRoot, { gitdir: worktreeGitDir, head: "ref: refs/heads/main\n", commondir: "../common-git", }); const { resolveCommitHash } = await import("./git-commit.js"); expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("bbbbbbb"); }); it("reads full HEAD refs before parsing long branch names", async () => { const temp = await makeTempDir("git-commit-long-head"); const repoRoot = path.join(temp, "repo"); const longRefName = `refs/heads/${"segment/".repeat(40)}main`; await makeFakeGitRepo(repoRoot, { head: `ref: ${longRefName}\n`, refs: { [longRefName]: "cccccccccccccccccccccccccccccccccccccccc", }, }); const { resolveCommitHash } = await import("./git-commit.js"); expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("ccccccc"); }); });