mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
580 lines
22 KiB
TypeScript
580 lines
22 KiB
TypeScript
// 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 --threads=1 --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);
|
|
});
|
|
});
|