// Git hook tests validate pre-commit hook behavior and scripts. import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js"; const baseGitEnv = { GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0", }; const baseRunEnv: NodeJS.ProcessEnv = { ...process.env, ...baseGitEnv }; const tempDirs: string[] = []; const run = (cwd: string, cmd: string, args: string[] = [], env?: NodeJS.ProcessEnv) => { return execFileSync(cmd, args, { cwd, encoding: "utf8", env: env ? { ...baseRunEnv, ...env } : baseRunEnv, }).trim(); }; type FailedCommand = { status: number; stderr: string; stdout: string; }; const runFailure = ( cwd: string, cmd: string, args: string[] = [], env?: NodeJS.ProcessEnv, ): FailedCommand => { try { run(cwd, cmd, args, env); } catch (error) { if (error instanceof Error && "status" in error) { const failure = error as Error & { status?: number; stderr?: string; stdout?: string }; return { status: failure.status ?? 1, stderr: String(failure.stderr ?? ""), stdout: String(failure.stdout ?? ""), }; } throw error; } throw new Error("expected command to fail"); }; function writeExecutable(dir: string, name: string, contents: string): void { writeFileSync(path.join(dir, name), contents, { encoding: "utf8", mode: 0o755, }); } function installPreCommitFixture(dir: string): string { mkdirSync(path.join(dir, "git-hooks"), { recursive: true }); mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true }); symlinkSync( path.join(process.cwd(), "git-hooks", "pre-commit"), path.join(dir, "git-hooks", "pre-commit"), ); writeFileSync( path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"), "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755, }, ); writeFileSync( path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"), "process.exit(0);\n", "utf8", ); const fakeBinDir = path.join(dir, "bin"); mkdirSync(fakeBinDir, { recursive: true }); writeExecutable(fakeBinDir, "node", "#!/usr/bin/env bash\nexit 0\n"); return fakeBinDir; } function installRunNodeToolFixture(dir: string): void { mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true }); symlinkSync( path.join(process.cwd(), "scripts", "pre-commit", "run-node-tool.sh"), path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"), ); } function splitNonEmptyLines(output: string): string[] { const lines: string[] = []; for (const line of output.split("\n")) { if (line) { lines.push(line); } } return lines; } afterEach(() => { cleanupTempDirs(tempDirs); }); describe("git-hooks/pre-commit (integration)", () => { it("does not treat staged filenames as git-add flags (e.g. --all)", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]); // Use the real hook script and lightweight helper stubs. const fakeBinDir = installPreCommitFixture(dir); // Create an untracked file that should NOT be staged by the hook. writeFileSync(path.join(dir, "secret.txt"), "do-not-stage\n", "utf8"); // Stage a maliciously-named file. Older hooks using `xargs git add` could run `git add --all`. writeFileSync(path.join(dir, "--all"), "flag\n", "utf8"); run(dir, "git", ["add", "--", "--all"]); // Run the hook directly (same logic as when installed via core.hooksPath). run(dir, "bash", ["git-hooks/pre-commit"], { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, }); const staged = splitNonEmptyLines(run(dir, "git", ["diff", "--cached", "--name-only"])); expect(staged).toEqual(["--all"]); }); it("does not run the changed-scope check for non-doc staged changes", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-no-check-changed-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]); const fakeBinDir = installPreCommitFixture(dir); writeFileSync(path.join(dir, "package.json"), '{"name":"tmp"}\n', "utf8"); writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); writeExecutable( fakeBinDir, "pnpm", "#!/usr/bin/env bash\necho 'pnpm should not run from pre-commit' >&2\nexit 99\n", ); writeFileSync(path.join(dir, "tracked.txt"), "hello\n", "utf8"); run(dir, "git", ["add", "--", "tracked.txt"]); run(dir, "bash", ["git-hooks/pre-commit"], { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, }); expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("tracked.txt"); }); it("does not re-add staged paths that are ignored by the current .gitignore", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-ignored-staged-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]); const fakeBinDir = installPreCommitFixture(dir); mkdirSync(path.join(dir, ".agents", "skills", "discord-clawd"), { recursive: true }); writeFileSync(path.join(dir, ".gitignore"), ".agents/skills/discord-clawd/\n", "utf8"); writeFileSync( path.join(dir, ".agents", "skills", "discord-clawd", "SKILL.md"), "# Discord Clawd\n", "utf8", ); run(dir, "git", ["add", "--", ".gitignore"]); run(dir, "git", ["add", "-f", "--", ".agents/skills/discord-clawd/SKILL.md"]); run(dir, "bash", ["git-hooks/pre-commit"], { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, }); const staged = splitNonEmptyLines(run(dir, "git", ["diff", "--cached", "--name-only"])); expect(staged).toEqual([".agents/skills/discord-clawd/SKILL.md", ".gitignore"]); }); it("ignores FAST_COMMIT because the hook is already formatting-only", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-fast-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]); const fakeBinDir = installPreCommitFixture(dir); writeFileSync(path.join(dir, "package.json"), '{"name":"tmp"}\n', "utf8"); writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); writeExecutable( fakeBinDir, "pnpm", "#!/usr/bin/env bash\necho 'pnpm should not run when FAST_COMMIT is enabled' >&2\nexit 99\n", ); writeFileSync(path.join(dir, "tracked.txt"), "hello\n", "utf8"); run(dir, "git", ["add", "--", "tracked.txt"]); run(dir, "bash", ["git-hooks/pre-commit"], { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, FAST_COMMIT: "1", }); expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("tracked.txt"); }); }); describe("scripts/pre-commit/run-node-tool.sh", () => { it("runs the installed local tool without invoking pnpm", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-run-node-tool-local-"); installRunNodeToolFixture(dir); writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); const fakeBinDir = path.join(dir, "bin"); const toolBinDir = path.join(dir, "node_modules", ".bin"); mkdirSync(fakeBinDir, { recursive: true }); mkdirSync(toolBinDir, { recursive: true }); writeExecutable( fakeBinDir, "pnpm", "#!/usr/bin/env bash\necho 'pnpm should not run from run-node-tool' >&2\nexit 99\n", ); writeExecutable(toolBinDir, "oxfmt", "#!/usr/bin/env bash\nprintf 'local:%s\\n' \"$*\"\n"); expect( run(dir, "bash", ["scripts/pre-commit/run-node-tool.sh", "oxfmt", "--write", "a.ts"], { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, }), ).toBe("local:--write a.ts"); }); it("fails before pnpm can hydrate dependencies when node_modules is missing", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-run-node-tool-missing-deps-"); installRunNodeToolFixture(dir); writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); const fakeBinDir = path.join(dir, "bin"); const markerPath = path.join(dir, "pnpm-called"); mkdirSync(fakeBinDir, { recursive: true }); writeExecutable( fakeBinDir, "pnpm", `#!/usr/bin/env bash\ntouch ${JSON.stringify(markerPath)}\nexit 99\n`, ); const result = runFailure( dir, "bash", ["scripts/pre-commit/run-node-tool.sh", "oxfmt", "--write", "a.ts"], { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}` }, ); expect(result.status).toBe(1); expect(result.stderr).toContain( "Missing repo dependencies: cannot run oxfmt without node_modules.", ); expect(existsSync(markerPath)).toBe(false); }); });