diff --git a/docs/scripts.md b/docs/scripts.md index ed3b75a1b..43c9a9bb8 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -15,6 +15,11 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI. - Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`). - Assume scripts are host‑specific; read them before running on a new machine. +## Git hooks + +- `scripts/setup-git-hooks.js`: best-effort setup for `core.hooksPath` when inside a git repo. +- `scripts/format-staged.js`: pre-commit formatter for staged `src/` and `test/` files. + ## Auth monitoring scripts Auth monitoring scripts are documented here: diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit new file mode 100755 index 000000000..c2fe5149b --- /dev/null +++ b/git-hooks/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +[ -z "$ROOT" ] && exit 0 +node "$ROOT/scripts/format-staged.js" diff --git a/package.json b/package.json index a08ec1c48..3180d1c60 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,9 @@ "CHANGELOG.md", "LICENSE", "scripts/postinstall.js", + "scripts/format-staged.js", + "scripts/setup-git-hooks.js", + "git-hooks/**", "dist/terminal/**", "dist/routing/**", "dist/utils/**", diff --git a/scripts/format-staged.js b/scripts/format-staged.js new file mode 100644 index 000000000..0ad2d7dd7 --- /dev/null +++ b/scripts/format-staged.js @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const OXFMT_EXTENSIONS = new Set([ + ".cjs", + ".js", + ".json", + ".jsonc", + ".jsx", + ".mjs", + ".ts", + ".tsx", +]); + +function getRepoRoot() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, ".."); +} + +function runGitCommand(args, options = {}) { + return spawnSync("git", args, { + cwd: options.cwd, + encoding: "utf-8", + stdio: options.stdio ?? "pipe", + }); +} + +function splitNullDelimited(value) { + if (!value) return []; + const text = String(value); + return text.split("\0").filter(Boolean); +} + +function normalizeGitPath(filePath) { + return filePath.replace(/\\/g, "/"); +} + +function filterOxfmtTargets(paths) { + return paths + .map(normalizeGitPath) + .filter((filePath) => + (filePath.startsWith("src/") || filePath.startsWith("test/")) && + OXFMT_EXTENSIONS.has(path.posix.extname(filePath)), + ); +} + +function findPartiallyStagedFiles(stagedFiles, unstagedFiles) { + const unstaged = new Set(unstagedFiles.map(normalizeGitPath)); + return stagedFiles.filter((filePath) => unstaged.has(normalizeGitPath(filePath))); +} + +function filterOutPartialTargets(targets, partialTargets) { + if (partialTargets.length === 0) return targets; + const partial = new Set(partialTargets.map(normalizeGitPath)); + return targets.filter((filePath) => !partial.has(normalizeGitPath(filePath))); +} + +function resolveOxfmtCommand(repoRoot) { + const binName = process.platform === "win32" ? "oxfmt.cmd" : "oxfmt"; + const local = path.join(repoRoot, "node_modules", ".bin", binName); + if (fs.existsSync(local)) { + return { command: local, args: [] }; + } + + const result = spawnSync("oxfmt", ["--version"], { stdio: "ignore" }); + if (result.status === 0) { + return { command: "oxfmt", args: [] }; + } + + return null; +} + +function getGitPaths(args, repoRoot) { + const result = runGitCommand(args, { cwd: repoRoot }); + if (result.status !== 0) return []; + return splitNullDelimited(result.stdout ?? ""); +} + +function formatFiles(repoRoot, oxfmt, files) { + const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], { + cwd: repoRoot, + stdio: "inherit", + }); + return result.status === 0; +} + +function stageFiles(repoRoot, files) { + if (files.length === 0) return true; + const result = runGitCommand(["add", "--", ...files], { cwd: repoRoot, stdio: "inherit" }); + return result.status === 0; +} + +function main() { + const repoRoot = getRepoRoot(); + const staged = getGitPaths([ + "diff", + "--cached", + "--name-only", + "-z", + "--diff-filter=ACMR", + ], repoRoot); + const targets = filterOxfmtTargets(staged); + if (targets.length === 0) return; + + const unstaged = getGitPaths(["diff", "--name-only", "-z"], repoRoot); + const partial = findPartiallyStagedFiles(targets, unstaged); + if (partial.length > 0) { + process.stderr.write("[pre-commit] Skipping partially staged files:\n"); + for (const filePath of partial) { + process.stderr.write(`- ${filePath}\n`); + } + process.stderr.write("Stage full files to format them automatically.\n"); + } + + const filteredTargets = filterOutPartialTargets(targets, partial); + if (filteredTargets.length === 0) return; + + const oxfmt = resolveOxfmtCommand(repoRoot); + if (!oxfmt) { + process.stderr.write("[pre-commit] oxfmt not found; skipping format.\n"); + return; + } + + if (!formatFiles(repoRoot, oxfmt, filteredTargets)) { + process.exitCode = 1; + return; + } + + if (!stageFiles(repoRoot, filteredTargets)) { + process.exitCode = 1; + } +} + +export { + filterOxfmtTargets, + filterOutPartialTargets, + findPartiallyStagedFiles, + getRepoRoot, + normalizeGitPath, + resolveOxfmtCommand, + splitNullDelimited, +}; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 4718ac9d9..3211b194f 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { setupGitHooks } from "./setup-git-hooks.js"; function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") { // Examples: @@ -252,6 +253,7 @@ function main() { process.chdir(repoRoot); ensureExecutable(path.join(repoRoot, "dist", "entry.js")); + setupGitHooks({ repoRoot }); if (!shouldApplyPnpmPatchedDependenciesFallback()) { return; diff --git a/scripts/setup-git-hooks.js b/scripts/setup-git-hooks.js new file mode 100644 index 000000000..1d3bb29cd --- /dev/null +++ b/scripts/setup-git-hooks.js @@ -0,0 +1,96 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_HOOKS_PATH = "git-hooks"; +const PRE_COMMIT_HOOK = "pre-commit"; + +function getRepoRoot() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, ".."); +} + +function runGitCommand(args, options = {}) { + return spawnSync("git", args, { + cwd: options.cwd, + encoding: "utf-8", + stdio: options.stdio ?? "pipe", + }); +} + +function ensureExecutable(targetPath) { + if (process.platform === "win32") return; + if (!fs.existsSync(targetPath)) return; + try { + const mode = fs.statSync(targetPath).mode & 0o777; + if (mode & 0o100) return; + fs.chmodSync(targetPath, 0o755); + } catch (err) { + console.warn(`[setup-git-hooks] chmod failed: ${err}`); + } +} + +function isGitAvailable({ repoRoot = getRepoRoot(), runGit = runGitCommand } = {}) { + const result = runGit(["--version"], { cwd: repoRoot, stdio: "ignore" }); + return result.status === 0; +} + +function isGitRepo({ repoRoot = getRepoRoot(), runGit = runGitCommand } = {}) { + const result = runGit(["rev-parse", "--is-inside-work-tree"], { + cwd: repoRoot, + stdio: "pipe", + }); + if (result.status !== 0) return false; + return String(result.stdout ?? "").trim() === "true"; +} + +function setHooksPath({ + repoRoot = getRepoRoot(), + hooksPath = DEFAULT_HOOKS_PATH, + runGit = runGitCommand, +} = {}) { + const result = runGit(["config", "core.hooksPath", hooksPath], { + cwd: repoRoot, + stdio: "ignore", + }); + return result.status === 0; +} + +function setupGitHooks({ + repoRoot = getRepoRoot(), + hooksPath = DEFAULT_HOOKS_PATH, + runGit = runGitCommand, +} = {}) { + if (!isGitAvailable({ repoRoot, runGit })) { + return { ok: false, reason: "git-missing" }; + } + + if (!isGitRepo({ repoRoot, runGit })) { + return { ok: false, reason: "not-repo" }; + } + + if (!setHooksPath({ repoRoot, hooksPath, runGit })) { + return { ok: false, reason: "config-failed" }; + } + + ensureExecutable(path.join(repoRoot, hooksPath, PRE_COMMIT_HOOK)); + + return { ok: true }; +} + +export { + DEFAULT_HOOKS_PATH, + PRE_COMMIT_HOOK, + ensureExecutable, + getRepoRoot, + isGitAvailable, + isGitRepo, + runGitCommand, + setHooksPath, + setupGitHooks, +}; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + setupGitHooks(); +} diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index 3dadde0aa..faedef7a7 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -9,13 +9,17 @@ type SandboxHashInput = { agentWorkspaceDir: string; }; +function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null { + return value === null || (typeof value !== "object" && typeof value !== "function"); +} + function normalizeForHash(value: unknown): unknown { if (value === undefined) return undefined; if (Array.isArray(value)) { - const normalized = value.map(normalizeForHash).filter((item) => item !== undefined); - const allPrimitive = normalized.every((item) => item === null || typeof item !== "object"); - if (allPrimitive) { - return [...normalized].sort((a, b) => String(a).localeCompare(String(b))); + const normalized = value.map(normalizeForHash).filter((item): item is unknown => item !== undefined); + const primitives = normalized.filter(isPrimitive); + if (primitives.length === normalized.length) { + return [...primitives].sort((a, b) => String(a).localeCompare(String(b))); } return normalized; } diff --git a/src/git-hooks.test.ts b/src/git-hooks.test.ts new file mode 100644 index 000000000..35a344912 --- /dev/null +++ b/src/git-hooks.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { + filterOxfmtTargets, + filterOutPartialTargets, + findPartiallyStagedFiles, + splitNullDelimited, +} from "../scripts/format-staged.js"; +import { setupGitHooks } from "../scripts/setup-git-hooks.js"; + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-hooks-")); +} + +describe("format-staged helpers", () => { + it("splits null-delimited output", () => { + expect(splitNullDelimited("a\0b\0")).toEqual(["a", "b"]); + expect(splitNullDelimited("")).toEqual([]); + }); + + it("filters oxfmt targets", () => { + const targets = filterOxfmtTargets([ + "src/app.ts", + "src/app.md", + "test/foo.tsx", + "scripts/dev.ts", + "test\\bar.js", + ]); + expect(targets).toEqual(["src/app.ts", "test/foo.tsx", "test/bar.js"]); + }); + + it("detects partially staged files", () => { + const partial = findPartiallyStagedFiles( + ["src/a.ts", "test/b.tsx"], + ["src/a.ts", "docs/readme.md"], + ); + expect(partial).toEqual(["src/a.ts"]); + }); + + it("filters out partial targets", () => { + const filtered = filterOutPartialTargets( + ["src/a.ts", "test/b.tsx", "test/c.ts"], + ["test/b.tsx"], + ); + expect(filtered).toEqual(["src/a.ts", "test/c.ts"]); + }); +}); + +describe("setupGitHooks", () => { + it("returns git-missing when git is unavailable", () => { + const runGit = vi.fn(() => ({ status: 1, stdout: "" })); + const result = setupGitHooks({ repoRoot: "/tmp", runGit }); + expect(result).toEqual({ ok: false, reason: "git-missing" }); + expect(runGit).toHaveBeenCalled(); + }); + + it("returns not-repo when not inside a work tree", () => { + const runGit = vi.fn((args) => { + if (args[0] === "--version") return { status: 0, stdout: "git version" }; + if (args[0] === "rev-parse") return { status: 0, stdout: "false" }; + return { status: 1, stdout: "" }; + }); + + const result = setupGitHooks({ repoRoot: "/tmp", runGit }); + expect(result).toEqual({ ok: false, reason: "not-repo" }); + }); + + it("configures hooks path when inside a repo", () => { + const repoRoot = makeTempDir(); + const hooksDir = path.join(repoRoot, "git-hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, "pre-commit"); + fs.writeFileSync(hookPath, "#!/bin/sh\n", "utf-8"); + fs.chmodSync(hookPath, 0o644); + + const runGit = vi.fn((args) => { + if (args[0] === "--version") return { status: 0, stdout: "git version" }; + if (args[0] === "rev-parse") return { status: 0, stdout: "true" }; + if (args[0] === "config") return { status: 0, stdout: "" }; + return { status: 1, stdout: "" }; + }); + + const result = setupGitHooks({ repoRoot, runGit }); + expect(result).toEqual({ ok: true }); + expect(runGit.mock.calls.some(([args]) => args[0] === "config")).toBe(true); + + if (process.platform !== "win32") { + const mode = fs.statSync(hookPath).mode & 0o777; + expect(mode & 0o100).toBeTruthy(); + } + + fs.rmSync(repoRoot, { recursive: true, force: true }); + }); +});