// Git hook tests validate pre-commit hook behavior and scripts. import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { commitArgs, createContentGuardFixture, installFormattingRecorder, installPreCommitFixture, readFormatterLog, literals, rulePath, ruleSetting, run, runFailure, stageContent as stage, writeExecutable, } from "./git-hooks-pre-commit.test-support.js"; import { cleanupTempDirs, makeTempDir as makeTempRepoRoot } from "./helpers/temp-dir.js"; const tempDirs: string[] = []; 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.each(["configured", "unconfigured", "external"])( "formats staged files with %s private rules", (mode) => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]); const fakeBinDir = installPreCommitFixture(dir); const logPath = installFormattingRecorder(dir); if (mode === "unconfigured") { run(dir, "git", ["config", "--local", "--unset", ruleSetting]); unlinkSync(path.join(dir, rulePath)); } else if (mode === "external") { const privateDir = makeTempRepoRoot(tempDirs, "openclaw-private-rules-"); const privatePath = path.join(privateDir, "private rules.txt"); copyFileSync(path.join(dir, rulePath), privatePath); unlinkSync(path.join(dir, rulePath)); run(dir, "git", ["config", "--local", ruleSetting, privatePath]); expect(run(dir, "git", ["config", "--path", "--get", ruleSetting])).toBe(privatePath); } writeFileSync( path.join(dir, "changed.ts"), mode === "unconfigured" ? literals[0] : "export const value = 1;\n", "utf8", ); run(dir, "git", ["add", "--", "changed.ts"]); run(dir, "bash", ["git-hooks/pre-commit"], { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, }); expect(readFormatterLog(logPath)).toEqual([ "oxfmt --write --no-error-on-unmatched-pattern changed.ts", ]); if (mode === "external") { writeFileSync(path.join(dir, "changed.ts"), literals[0]); run(dir, "git", ["add", "--", "changed.ts"]); expect(runFailure(dir, "bash", ["git-hooks/pre-commit"]).stderr).toContain( "Blocked staged content", ); } }, ); it("formats only staged bytes of a partially staged file and preserves the working tree", () => { const dir = createContentGuardFixture(tempDirs); writeExecutable( path.join(dir, "node_modules/.bin"), "oxfmt", `#!/usr/bin/env bash set -euo pipefail printf 'oxfmt %s\n' "$*" >> hook-tool.log case "$*" in *--stdin-filepath=*) sed 's/FORMAT_ME/FORMATTED/' ;; esac `, ); const staged = "export const value = FORMAT_ME;\n"; const working = `${staged}export const unstagedOnly = 1;\n`; stage(dir, "partial.ts", staged); writeFileSync(path.join(dir, "partial.ts"), working); run(dir, "git", commitArgs); expect(run(dir, "git", ["show", "HEAD:partial.ts"])).toBe("export const value = FORMATTED;"); expect(readFileSync(path.join(dir, "partial.ts"), "utf8")).toBe(working); expect(readFormatterLog(path.join(dir, "hook-tool.log"))).toEqual([ "oxfmt --stdin-filepath=partial.ts", ]); }); it("preserves formatted staged content when the working-tree copy is deleted", () => { const dir = createContentGuardFixture(tempDirs); stage(dir, "gone.ts", "export const keep = 1;\n"); unlinkSync(path.join(dir, "gone.ts")); run(dir, "git", commitArgs); expect(run(dir, "git", ["show", "HEAD:gone.ts"])).toBe("export const keep = 1;"); expect(existsSync(path.join(dir, "gone.ts"))).toBe(false); }); it("leaves staged symlinks untouched even when retargeted in the working tree", () => { const dir = createContentGuardFixture(tempDirs); writeFileSync(path.join(dir, "target-a.ts"), "const unformatted = 1\n", "utf8"); writeFileSync(path.join(dir, "target-b.ts"), "const other = 2;\n", "utf8"); symlinkSync("target-a.ts", path.join(dir, "alias.ts")); run(dir, "git", ["add", "--", "alias.ts"]); unlinkSync(path.join(dir, "alias.ts")); symlinkSync("target-b.ts", path.join(dir, "alias.ts")); run(dir, "git", commitArgs); expect(run(dir, "git", ["show", "HEAD:alias.ts"])).toBe("target-a.ts"); expect(readFileSync(path.join(dir, "alias.ts"), "utf8")).toBe("const other = 2;\n"); }); it("fails instead of staging empty formatter output for a partially staged file", () => { const dir = createContentGuardFixture(tempDirs); // Drain stdin so SIGPIPE cannot preempt the hook's empty-output check. writeExecutable( path.join(dir, "node_modules/.bin"), "oxfmt", "#!/bin/sh\ncat >/dev/null\nexit 0\n", ); stage(dir, "partial.ts", "export const value = 1;\n"); writeFileSync( path.join(dir, "partial.ts"), "export const value = 1;\nexport const extra = 2;\n", ); const result = runFailure(dir, "git", commitArgs); expect(result.stderr).toContain("Formatter returned no output"); expect(run(dir, "git", ["show", ":partial.ts"])).toBe("export const value = 1;"); expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0); }); 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("does not invoke pnpm when FAST_COMMIT is set", () => { 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("staged content guard", () => { const fixture = () => createContentGuardFixture(tempDirs); function blocked(dir: string, names: string[], commit = false) { const result = commit ? runFailure(dir, "git", commitArgs) : runFailure(dir, "bash", ["git-hooks/pre-commit"]); const output = result.stdout + result.stderr; expect(result.status).toBe(1); expect(output).toContain("Blocked staged content"); expect(output).toContain("restage"); for (const name of names) { expect(output).toContain(JSON.stringify(name)); } for (const literal of literals) { expect(output).not.toContain(literal); } expect(output).not.toContain("PRIVATE_SOURCE_CONTEXT"); return result; } it.each(literals)( "blocks staged literal %s before formatting even with a clean working tree", (literal) => { const dir = fixture(); const log = installFormattingRecorder(dir); stage(dir, "payload.ts", `PRIVATE_SOURCE_CONTEXT prefix${literal}suffix\n`); writeFileSync(path.join(dir, "payload.ts"), "clean working tree\n"); blocked(dir, ["payload.ts"], true); expect(readFormatterLog(log)).toEqual([]); expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0); }, ); it.each(["payload.txt", "payload.ts"])( "keeps unstaged working-tree bytes out of the commit: %s", (name) => { const dir = fixture(); stage(dir, name, "clean staged version\n"); writeFileSync(path.join(dir, name), literals[0]); run(dir, "git", commitArgs); expect(run(dir, "git", ["show", `HEAD:${name}`])).toBe("clean staged version"); expect(readFileSync(path.join(dir, name), "utf8")).toBe(literals[0]); }, ); it("discovers a new path staged during formatting", () => { const dir = fixture(); stage(dir, "payload.ts", "clean\n"); writeFileSync(path.join(dir, "introduced.txt"), literals[1]); const log = installFormattingRecorder(dir, "git add -- introduced.txt"); blocked(dir, ["introduced.txt"], true); expect(readFormatterLog(log)).toHaveLength(1); }); it("uses fixed, case-sensitive matches despite Git grep defaults", () => { const dir = fixture(); run(dir, "git", ["config", "grep.patternType", "extended"]); run(dir, "git", ["config", "grep.ignoreCase", "true"]); stage(dir, "payload.txt", `${literals[0].toLowerCase()}\nGUARD_SYNTHETIC_BETA_xanything42\n`); run(dir, "git", commitArgs); expect(run(dir, "git", ["show", "HEAD:payload.txt"])).toContain("xanything42"); }); it("scans unchanged lines in modified files but permits unchanged history and deletion-only commits", () => { const dir = fixture(); stage(dir, "historical.txt", `${literals[0]}\nold line\n`); run(dir, "git", ["commit", "-qm", "historical fixture"]); stage(dir, "clean.txt", "clean\n"); run(dir, "git", commitArgs); stage(dir, "historical.txt", `${literals[0]}\nnew line\n`); blocked(dir, ["historical.txt"]); run(dir, "git", ["rm", "-f", "--", "historical.txt"]); run(dir, "git", commitArgs); expect(run(dir, "git", ["ls-tree", "--name-only", "HEAD"])).toBe("clean.txt"); }); it.each(["rename", "typechange", "binary"])("scans the full staged blob for %s", (kind) => { const dir = fixture(); if (kind === "rename") { stage(dir, "old.txt", literals[0]); run(dir, "git", ["commit", "-qm", "historical fixture"]); run(dir, "git", ["mv", "--", "old.txt", "payload.txt"]); } else if (kind === "typechange") { symlinkSync("absent-target", path.join(dir, "payload.txt")); run(dir, "git", ["add", "--", "payload.txt"]); run(dir, "git", ["commit", "-qm", "symlink fixture"]); unlinkSync(path.join(dir, "payload.txt")); stage(dir, "payload.txt", literals[0]); } else { stage( dir, "payload.txt", Buffer.concat([Buffer.from([0, 255]), Buffer.from(literals[1]), Buffer.from([0])]), ); } blocked(dir, ["payload.txt"]); }); it("reports literal paths safely and includes ignored docs, tests and generated files", () => { const dir = fixture(); writeFileSync(path.join(dir, ".gitignore"), "ignored/\n"); const names = [ "space name.txt", "--all", ":(exclude)payload.txt", "[literal]*?.txt", "line\nbreak.txt", "control\u001b.txt", "ignored/file.txt", "docs/example.md", "test/example.ts", "extensions/example/src/host/web/file.bundle.js", ]; for (const name of names) { stage(dir, name, literals[0]); } stage(dir, `${literals[0]}.txt`, literals[1]); const result = blocked(dir, [...names, "[REDACTED].txt"]); expect(result.stderr).not.toContain("\u001b"); }); it("scans the former public rule filename and beyond both batch limits", () => { const dir = fixture(); const formerRulePath = "scripts/pre-commit/blocked-literals.txt"; stage(dir, formerRulePath, literals[0]); blocked(dir, [formerRulePath]); // Long paths cross the byte budget before 64 entries; short paths cross the count budget. const batchPaths = []; for (let i = 0; i < 140; i++) { const suffix = i < 70 ? `/${"x".repeat(180)}/${"y".repeat(180)}` : ""; const name = `batch-${String(i).padStart(3, "0")}${suffix}.txt`; mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); writeFileSync(path.join(dir, name), "clean\n"); batchPaths.push(name); } run(dir, "git", ["add", "--", ...batchPaths]); stage(dir, formerRulePath, "clean\n"); stage(dir, "zzz-last.txt", literals[1]); blocked(dir, ["zzz-last.txt"]); }); it("permits unborn and existing empty commits and ignores submodule contents", () => { const dir = fixture(); run(dir, "git", [...commitArgs, "--allow-empty"]); run(dir, "git", [...commitArgs, "--allow-empty"]); const head = run(dir, "git", ["rev-parse", "HEAD"]); run(dir, "git", ["update-index", "--add", "--cacheinfo", `160000,${head},submodule`]); mkdirSync(path.join(dir, "submodule")); writeFileSync(path.join(dir, "submodule", "payload.txt"), literals[0]); run(dir, "bash", ["git-hooks/pre-commit"]); expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("submodule"); }); it.each([ ["missing file", null], ["empty file", ""], ["blank lines", "\n\n"], ["invalid UTF-8", Buffer.from([255])], ["NUL literal", "\0"], ["empty setting", undefined], ])("fails closed with %s", (_label, content) => { const dir = fixture(); const log = installFormattingRecorder(dir); stage(dir, "payload.ts", "clean\n"); if (content === undefined) { run(dir, "git", ["config", "--local", ruleSetting, ""]); } else if (content === null) { unlinkSync(path.join(dir, rulePath)); } else { writeFileSync(path.join(dir, rulePath), content); } const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]); expect(result.status).toBe(1); expect(result.stderr).toContain(ruleSetting); expect(result.stderr).toContain("retry"); expect(result.stdout + result.stderr).not.toContain(path.join(dir, rulePath)); expect(readFormatterLog(log)).toEqual([]); }); it.each([ ["literal metacharacters", [...literals], literals.join(" "), "[REDACTED] [REDACTED]"], [ "shorter prefix first", ["foo", "foobar"], "foobar foo FOOBAR", "[REDACTED] [REDACTED] FOOBAR", ], ["longer prefix first", ["foobar", "foo"], "foobar foo FOOBAR", "[REDACTED] [REDACTED] FOOBAR"], ["crossing overlaps", ["abc", "bcd"], "abcd", "[REDACTED]"], ["reversed crossing overlaps", ["bcd", "abc"], "abcd", "[REDACTED]"], ["self-overlap", ["aba"], "ababa", "[REDACTED]"], ["marker literal", ["foo", "REDACTED"], "foo REDACTED", "[REDACTED] [REDACTED]"], ])( "redacts filenames and formatter streams with %s while preserving failure status", (_label, rules, text, redacted) => { const dir = fixture(); writeFileSync(path.join(dir, rulePath), `${rules.join("\n")}\n`); const name = `report-${text}\n🦞.ts`; stage(dir, name, text); const finding = runFailure(dir, "bash", ["git-hooks/pre-commit"]); stage(dir, name, "clean\n"); const context = `🦞 café ${text}\nuntouched ${text} tail\n`; const expected = `🦞 café ${redacted}\nuntouched ${redacted} tail\n`; installFormattingRecorder( dir, `printf 'stdout %s' '${context}'\nprintf 'stderr %s' '${context}' >&2\nprintf broken > .git/index\nexit 23`, ); const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]); expect(result).toEqual({ status: 23, stdout: `stdout ${expected}`, stderr: `stderr ${expected}[pre-commit] Formatter failed. Fix the reported error and retry.\n[pre-commit] FAILED (exit 23)\n`, }); expect(finding.status).toBe(1); expect(finding.stderr).toContain(` ${JSON.stringify(`report-${redacted}\n🦞.ts`)}\n`); }, ); it.each(["config path", "index", "blob", "post-format blob"])( "blocks Git %s read errors without raw diagnostics", (kind) => { const dir = fixture(); const name = `${literals[0]}.txt`; stage(dir, name, "clean\n"); if (kind === "config path") { run(dir, "git", ["config", "--local", ruleSetting, `~${literals[1]}/private rules.txt`]); expect(runFailure(dir, "git", ["config", "--path", "--get", ruleSetting]).status).not.toBe( 1, ); } else if (kind === "index") { writeFileSync(path.join(dir, ".git/index"), literals[1]); } else { const oid = run(dir, "git", ["rev-parse", `:${name}`]); const objectPath = `.git/objects/${oid.slice(0, 2)}/${oid.slice(2)}`; if (kind === "post-format blob") { // Keep git add from recreating the missing blob before the post-scan. writeFileSync(path.join(dir, ".gitignore"), "*.txt\n"); stage(dir, "trigger.ts", "formatter trigger\n"); installFormattingRecorder(dir, `rm -- '${objectPath}'`); } else { unlinkSync(path.join(dir, objectPath)); const grep = runFailure(dir, "git", [ "grep", "--cached", "--fixed-strings", "clean", "--", name, ]); expect(grep.status).toBe(1); expect(grep.stderr.length).toBeGreaterThan(0); } } const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]); expect(result.stderr).toContain("Git could not"); for (const literal of literals) { expect(result.stdout + result.stderr).not.toContain(literal); } expect(result.stderr).not.toContain("error:"); }, ); }); 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); }); });