Files
Moltbot/src/infra/git-commit.test.ts
2026-03-08 18:40:15 +00:00

365 lines
13 KiB
TypeScript

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<string> {
return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`));
}
async function makeFakeGitRepo(
root: string,
options: {
head: string;
refs?: Record<string, string>;
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");
});
});