From 66298bf015324414f898e50bd324c50ad457f56c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:50:51 -0400 Subject: [PATCH 01/13] feat(guardrails): serve the git hooks to lefthook repositories as a remote Shared scope with lefthook now commits one lefthook `remotes:` entry instead of copies under .githooks/guardrails/: lefthook fetches plugins/guardrails/git-hooks/lefthook-remote.yml and its scripts from this repository's main and refetches them every 24h, so every consumer runs the current hooks with no refresh pull request and no lint exclusion. - lib/git-hooks/install.mjs (and its 10 generated copies): an optional `remote` block in entries.json; `plan --scope remote` returns the snippet, lefthook-install, migrate (from: shared) and remote-needs-lefthook decisions; verify reports ok only when the config carries the remote, the event hook is lefthook's and the remote is fetched. Plugins without the block are unchanged. - .lefthook//guardrails-.sh: lefthook looks up a remote's scripts under the remote repository's root .lefthook/; each runs the plugin's own entry script. - Remote job names are guardrails-remote-: lefthook merges a job of the same name across configs, and the shared-copy job's run: plus the remote's script: fail as one job. Co-Authored-By: Claude Opus 5.5 --- ...eat-guardrails-lefthook-remote-663be064.md | 7 + ...eat-guardrails-lefthook-remote-66f960c2.md | 7 + ...eat-guardrails-lefthook-remote-4ac659d2.md | 7 + ...eat-guardrails-lefthook-remote-12d43cd7.md | 7 + ...eat-guardrails-lefthook-remote-51ab54cf.md | 7 + ...eat-guardrails-lefthook-remote-6f491184.md | 7 + ...eat-guardrails-lefthook-remote-821cb784.md | 7 + ...eat-guardrails-lefthook-remote-433a1c9a.md | 7 + ...eat-guardrails-lefthook-remote-f22965d4.md | 7 + ...eat-guardrails-lefthook-remote-5598ea80.md | 7 + .../guardrails-commit-msg-convention.sh | 5 + .../guardrails-pre-commit-content.sh | 5 + .../pre-push/guardrails-reference-verify.sh | 5 + lib/git-hooks/install.mjs | 96 ++++++++++++- lib/git-hooks/install.test.mjs | 117 +++++++++++++++- plugins/actionlint/lib/git-hooks/install.mjs | 96 ++++++++++++- plugins/bash-format/lib/git-hooks/install.mjs | 96 ++++++++++++- .../biome-format/lib/git-hooks/install.mjs | 96 ++++++++++++- plugins/go-format/lib/git-hooks/install.mjs | 96 ++++++++++++- plugins/guardrails/README.md | 5 +- plugins/guardrails/git-hooks/entries.json | 5 + .../git-hooks/lefthook-remote.test.sh | 127 ++++++++++++++++++ .../guardrails/git-hooks/lefthook-remote.yml | 21 +++ plugins/guardrails/lib/git-hooks/install.mjs | 96 ++++++++++++- plugins/guardrails/skills/setup/SKILL.md | 19 ++- .../setup/reference/git-hook-decisions.md | 7 +- .../lib/git-hooks/install.mjs | 96 ++++++++++++- .../markdown-format/lib/git-hooks/install.mjs | 96 ++++++++++++- .../lib/git-hooks/install.mjs | 96 ++++++++++++- plugins/ruff-format/lib/git-hooks/install.mjs | 96 ++++++++++++- .../typos-format/lib/git-hooks/install.mjs | 96 ++++++++++++- 31 files changed, 1364 insertions(+), 78 deletions(-) create mode 100644 .changes/actionlint/feat-guardrails-lefthook-remote-663be064.md create mode 100644 .changes/bash-format/feat-guardrails-lefthook-remote-66f960c2.md create mode 100644 .changes/biome-format/feat-guardrails-lefthook-remote-4ac659d2.md create mode 100644 .changes/go-format/feat-guardrails-lefthook-remote-12d43cd7.md create mode 100644 .changes/guardrails/feat-guardrails-lefthook-remote-51ab54cf.md create mode 100644 .changes/instruction-placement/feat-guardrails-lefthook-remote-6f491184.md create mode 100644 .changes/markdown-format/feat-guardrails-lefthook-remote-821cb784.md create mode 100644 .changes/powershell-format/feat-guardrails-lefthook-remote-433a1c9a.md create mode 100644 .changes/ruff-format/feat-guardrails-lefthook-remote-f22965d4.md create mode 100644 .changes/typos-format/feat-guardrails-lefthook-remote-5598ea80.md create mode 100755 .lefthook/commit-msg/guardrails-commit-msg-convention.sh create mode 100755 .lefthook/pre-commit/guardrails-pre-commit-content.sh create mode 100755 .lefthook/pre-push/guardrails-reference-verify.sh create mode 100755 plugins/guardrails/git-hooks/lefthook-remote.test.sh create mode 100644 plugins/guardrails/git-hooks/lefthook-remote.yml diff --git a/.changes/actionlint/feat-guardrails-lefthook-remote-663be064.md b/.changes/actionlint/feat-guardrails-lefthook-remote-663be064.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/actionlint/feat-guardrails-lefthook-remote-663be064.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/bash-format/feat-guardrails-lefthook-remote-66f960c2.md b/.changes/bash-format/feat-guardrails-lefthook-remote-66f960c2.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/bash-format/feat-guardrails-lefthook-remote-66f960c2.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/biome-format/feat-guardrails-lefthook-remote-4ac659d2.md b/.changes/biome-format/feat-guardrails-lefthook-remote-4ac659d2.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/biome-format/feat-guardrails-lefthook-remote-4ac659d2.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/go-format/feat-guardrails-lefthook-remote-12d43cd7.md b/.changes/go-format/feat-guardrails-lefthook-remote-12d43cd7.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/go-format/feat-guardrails-lefthook-remote-12d43cd7.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/guardrails/feat-guardrails-lefthook-remote-51ab54cf.md b/.changes/guardrails/feat-guardrails-lefthook-remote-51ab54cf.md new file mode 100644 index 0000000000..bda56cc6f1 --- /dev/null +++ b/.changes/guardrails/feat-guardrails-lefthook-remote-51ab54cf.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- `apply install-git-hooks scope=shared` in a lefthook repository, or one with no hook manager yet, now adds one `remotes:` entry to the committed `lefthook.yml` instead of committing copies under `.githooks/guardrails/`: lefthook fetches `git-hooks/lefthook-remote.yml` and its scripts from this marketplace repository's `main` and refetches them every 24 hours, so every clone runs the current hooks with no refresh pull request and no lint exclusion. `install.mjs plan --scope remote` returns the snippet; `verify` reports each entry `ok` once the config carries the remote, `lefthook install` has written the event hook and the remote is fetched, and `missing` with "run lefthook install" otherwise, so the session-start notice also catches a first install made offline. A repository holding the earlier shared copies gets a `migrate` decision (`from: shared`) naming `.githooks/guardrails/` and the old `guardrails-` jobs to remove. Husky and plain hooks keep the shared copies. diff --git a/.changes/instruction-placement/feat-guardrails-lefthook-remote-6f491184.md b/.changes/instruction-placement/feat-guardrails-lefthook-remote-6f491184.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/instruction-placement/feat-guardrails-lefthook-remote-6f491184.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/markdown-format/feat-guardrails-lefthook-remote-821cb784.md b/.changes/markdown-format/feat-guardrails-lefthook-remote-821cb784.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/markdown-format/feat-guardrails-lefthook-remote-821cb784.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/powershell-format/feat-guardrails-lefthook-remote-433a1c9a.md b/.changes/powershell-format/feat-guardrails-lefthook-remote-433a1c9a.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/powershell-format/feat-guardrails-lefthook-remote-433a1c9a.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/ruff-format/feat-guardrails-lefthook-remote-f22965d4.md b/.changes/ruff-format/feat-guardrails-lefthook-remote-f22965d4.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/ruff-format/feat-guardrails-lefthook-remote-f22965d4.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.changes/typos-format/feat-guardrails-lefthook-remote-5598ea80.md b/.changes/typos-format/feat-guardrails-lefthook-remote-5598ea80.md new file mode 100644 index 0000000000..b91bdc90b1 --- /dev/null +++ b/.changes/typos-format/feat-guardrails-lefthook-remote-5598ea80.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The vendored git-hook installer (`lib/git-hooks/install.mjs`) accepts an optional `remote` block in `git-hooks/entries.json` and a `plan --scope remote` that returns a lefthook `remotes:` entry for it; a plugin without the block behaves as before, and `copy --scope remote` is a usage error. diff --git a/.lefthook/commit-msg/guardrails-commit-msg-convention.sh b/.lefthook/commit-msg/guardrails-commit-msg-convention.sh new file mode 100755 index 0000000000..9a81039d98 --- /dev/null +++ b/.lefthook/commit-msg/guardrails-commit-msg-convention.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# The guardrails commit-msg-convention entry for consumers of the plugin's lefthook remote +# (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts +# under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. +exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/commit-msg-convention.sh" "$@" diff --git a/.lefthook/pre-commit/guardrails-pre-commit-content.sh b/.lefthook/pre-commit/guardrails-pre-commit-content.sh new file mode 100755 index 0000000000..ea4c932c88 --- /dev/null +++ b/.lefthook/pre-commit/guardrails-pre-commit-content.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# The guardrails pre-commit-content entry for consumers of the plugin's lefthook remote +# (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts +# under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. +exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/pre-commit-content.sh" "$@" diff --git a/.lefthook/pre-push/guardrails-reference-verify.sh b/.lefthook/pre-push/guardrails-reference-verify.sh new file mode 100755 index 0000000000..a72ad2a516 --- /dev/null +++ b/.lefthook/pre-push/guardrails-reference-verify.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# The guardrails reference-verify entry for consumers of the plugin's lefthook remote +# (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts +# under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. +exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/reference-verify.sh" "$@" diff --git a/lib/git-hooks/install.mjs b/lib/git-hooks/install.mjs index efa9a6ca20..3d5b8479d8 100755 --- a/lib/git-hooks/install.mjs +++ b/lib/git-hooks/install.mjs @@ -5,7 +5,7 @@ // // install.mjs detect [common] // install.mjs list [common] -// install.mjs plan --scope local|shared [--manager ] [common] +// install.mjs plan --scope local|shared|remote [--manager ] [common] // install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force] [common] // install.mjs decline --entry [--undo] [--global] [common] // install.mjs verify [--quiet] [common] @@ -20,6 +20,8 @@ // `git config --local` decline key. Everything else (edits to lefthook or husky // config, a foreign hook, a manager conflict, an exclude line, a migration, a // missing tool) comes back from plan as a decisions[] item for the setup agent. +// --scope remote, for a plugin whose entries.json names a lefthook remote, +// writes nothing: plan returns the `remotes:` entry for the lefthook config. // // Exit 0 done; 1 verify found drift, or copy refused a locally modified copy or // a target that is or passes through a symlink; @@ -188,12 +190,26 @@ function assetErrors(assets, where) { const assetName = (asset) => path.posix.basename(asset.replace(/\\/g, "/")); +// remote: optional { git_url, ref, config } naming a lefthook remote config, +// config being its path inside that repository. +function remoteErrors(remote) { + if (remote === undefined) return []; + const errors = []; + if (!/^(https:\/\/|git@)\S+$/.test(remote?.git_url ?? "")) errors.push("remote.git_url must be an https or ssh git URL"); + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(remote?.ref ?? "")) errors.push("remote.ref must be a branch or tag name"); + const config = remote?.config ?? ""; + if (!/\.ya?ml$/.test(config) || path.isAbsolute(config) || config.split(/[\\/]/).includes("..")) { + errors.push("remote.config must be a relative .yml path inside the remote repository"); + } + return errors; +} + // validateEntries(manifest) -> list of errors; empty means valid. export function validateEntries(manifest) { if (!Array.isArray(manifest?.entries) || manifest.entries.length === 0) { return ["entries.json needs a non-empty entries list"]; } - const errors = manifest.entries.flatMap(entryErrors); + const errors = [...manifest.entries.flatMap(entryErrors), ...remoteErrors(manifest.remote)]; const names = manifest.entries.map((e) => e?.name); if (new Set(names).size !== names.length) errors.push("entries.json names an entry twice"); return errors; @@ -230,7 +246,7 @@ export function loadPlugin(root) { if (errors.length) throw new UsageError(`${name}: ${errors.join("; ")}`); const libText = readText(path.join(root, "lib", "git-hooks", LIB_NAME)); if (libText === null) throw new UsageError(`${name}: lib/git-hooks/${LIB_NAME} is missing`); - return { name, root, entries: manifest.entries, libText }; + return { name, root, entries: manifest.entries, remote: manifest.remote, libText }; } // stampHash(script, lib, assets): sha256 over the LF-normalized script, a NUL, @@ -523,6 +539,66 @@ function migrateDecision(info, plugin, scope, manager) { return files.length ? [{ kind: "migrate", from: "local", files }] : []; } +// The plugin's lefthook remote: the `remotes:` entry, whether the lefthook +// config carries it, and whether lefthook fetched it, into +// /lefthook-remotes/-/ (lefthook 2.2.2). +const repoName = (url) => url.replace(/\.git$/, "").split(/[/:]/).pop(); +const repoSlug = (url) => url.replace(/\.git$/, "").split(/[/:]/).slice(-2).join("/"); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +function remoteConfigured(info, plugin) { + const text = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + return text.includes(repoSlug(plugin.remote.git_url)) && text.includes(plugin.remote.config); +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const dir = path.join(path.dirname(info.excludeFile), "lefthook-remotes", `${repoName(url)}-${ref}`); + return readText(path.join(dir, config)) !== null; +} + +const lefthookHook = (info, event) => Boolean(readText(path.join(info.hooksDir, event))?.includes("lefthook")); + +// The shared copies and their lefthook jobs that the remote replaces. +function sharedLeftovers(info, plugin) { + const files = plugin.entries.map((e) => entryPaths(info, plugin, e, "shared", "lefthook").script).filter((f) => existsSync(f)); + const config = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + const jobs = plugin.entries + .map((e) => `${plugin.name}-${e.name}`) + .filter((job) => new RegExp(`name:\\s*${job}\\s*$`, "m").test(config)); + if (!files.length && !jobs.length) return []; + return [{ kind: "migrate", from: "shared", dir: `${SHARED_DIR}/${plugin.name}`, files, jobs }]; +} + +function remotePlan(info, plugin, override) { + if (!plugin.remote) throw new UsageError(`${plugin.name} names no lefthook remote; use --scope local or shared`); + const found = detect(info); + const manager = override ?? (found.evidence.length ? found.manager : "lefthook"); + if (manager === "conflict") { + return { manager, actions: [], decisions: [{ kind: "manager-conflict", evidence: found.evidence }] }; + } + if (manager !== "lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-needs-lefthook", evidence: found.evidence }] }; + } + const decisions = []; + if (!remoteConfigured(info, plugin)) { + decisions.push({ kind: "lefthook-config", file: lefthookFile(info, "shared"), snippet: remoteSnippet(plugin.remote) }); + } + if (!plugin.entries.every((e) => lefthookHook(info, e.event)) || !remoteFetched(info, plugin)) { + decisions.push({ kind: "lefthook-install", command: "lefthook install" }); + } + decisions.push(...sharedLeftovers(info, plugin)); + for (const entry of plugin.entries) { + if (isDeclined(info, plugin, entry)) decisions.push({ kind: "declined", entry: entry.name }); + decisions.push(...toolDecisions(info, plugin, entry)); + } + return { manager, actions: [], decisions }; +} + // plan(info, plugin, scope, manager?) -> { manager, actions[], decisions[] } // A local install writes into `git rev-parse --git-path hooks`. With // core.hooksPath set to anything but the plain shared .githooks, that directory @@ -535,6 +611,7 @@ function hooksDirOwned(info, scope, manager) { } export function plan(info, plugin, scope, override) { + if (scope === "remote") return remotePlan(info, plugin, override); const found = detect(info); const manager = override ?? found.manager; if (manager === "conflict") { @@ -714,6 +791,10 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + if (plugin.remote && remoteConfigured(info, plugin)) { + if (lefthookHook(info, entry.event) && remoteFetched(info, plugin)) return { state: "ok" }; + return { state: "missing", detail: "lefthook remote not fetched or hook not installed; run lefthook install" }; + } const current = sourceHash(plugin, entry); const copies = list(info, plugin).filter((c) => c.entryId === entry.name); if (copies.length === 0) return { state: "missing", detail: "not installed" }; @@ -742,7 +823,7 @@ export function driftLine(plugin, row) { const USAGE = [ "usage: install.mjs detect|list [--repo ] [--plugin-root ]", - " install.mjs plan --scope local|shared [--manager lefthook|husky|plain]", + " install.mjs plan --scope local|shared|remote [--manager lefthook|husky|plain]", " install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force]", " install.mjs decline --entry [--undo] [--global]", " install.mjs verify [--quiet]", @@ -763,8 +844,11 @@ function parseArgs(argv, defaults) { else if (arg === "--entry" && rest[i + 1]) opts.entries.push(rest[++i]); else throw new UsageError(`unexpected argument: ${arg}`); } - if (["plan", "copy"].includes(action) && !["local", "shared"].includes(opts.scope)) { - throw new UsageError(`${action} needs --scope local or --scope shared`); + if (action === "plan" && !["local", "shared", "remote"].includes(opts.scope)) { + throw new UsageError("plan needs --scope local, shared or remote"); + } + if (action === "copy" && !["local", "shared"].includes(opts.scope)) { + throw new UsageError("copy needs --scope local or --scope shared; a remote install writes nothing, so apply plan's decisions"); } if (opts.manager && !MANAGERS.includes(opts.manager)) throw new UsageError(`unknown manager: ${opts.manager}`); if (action === "decline" && opts.entries.length !== 1) throw new UsageError("decline needs one --entry"); diff --git a/lib/git-hooks/install.test.mjs b/lib/git-hooks/install.test.mjs index d23e051c12..b2cb4e09ab 100644 --- a/lib/git-hooks/install.test.mjs +++ b/lib/git-hooks/install.test.mjs @@ -80,14 +80,14 @@ cat "$CHECKS_TMP/blocking" >&2 exit 1 `; -function plugin(entries) { +function plugin(entries, manifest = {}) { const root = path.join(work, `plugin${++counter}`); mkdirSync(path.join(root, ".claude-plugin"), { recursive: true }); mkdirSync(path.join(root, "git-hooks")); mkdirSync(path.join(root, "lib", "git-hooks"), { recursive: true }); writeFileSync(path.join(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "demo-checks" })); const list = entries ?? [{ name: "word-check", event: "pre-commit", script: "word-check.sh", tools: ["grep"], rewrites: false }]; - writeFileSync(path.join(root, "git-hooks", "entries.json"), JSON.stringify({ entries: list })); + writeFileSync(path.join(root, "git-hooks", "entries.json"), JSON.stringify({ ...manifest, entries: list })); writeFileSync(path.join(root, "git-hooks", "word-check.sh"), WORD_CHECK); copyFileSync(path.join(here, "check-lib.sh"), path.join(root, "lib", "git-hooks", "check-lib.sh")); const prereq = { @@ -599,6 +599,119 @@ describe("drift", () => { }); }); +// A plugin whose entries.json names a lefthook remote: the consumer's lefthook +// config carries one `remotes:` entry and the installer writes nothing. +const REMOTE = { + git_url: "https://example.invalid/demo/checks-repo", + ref: "main", + config: "plugins/demo-checks/git-hooks/lefthook-remote.yml", +}; +const REMOTE_SNIPPET = [ + "remotes:", + " - git_url: https://example.invalid/demo/checks-repo", + " ref: main", + " refetch_frequency: 24h", + " configs:", + " - plugins/demo-checks/git-hooks/lefthook-remote.yml", +].join("\n"); + +describe("remote", () => { + const remotePlugin = () => plugin(undefined, { remote: REMOTE }); + // What `lefthook install` leaves behind: the event hook it writes and the + // fetched copy under /lefthook-remotes/-/. + function fetched(dir) { + writeFileSync(path.join(dir, ".git", "hooks", "pre-commit"), "#!/bin/sh\ncall_lefthook run pre-commit\n"); + const config = path.join(dir, ".git", "info", "lefthook-remotes", "checks-repo-main", REMOTE.config); + mkdirSync(path.dirname(config), { recursive: true }); + writeFileSync(config, "pre-commit:\n"); + } + + test("a malformed remote block is rejected, a well-formed one passes", () => { + const entries = [{ name: "word-check", event: "pre-commit", script: "word-check.sh", tools: [], rewrites: false }]; + assert.deepEqual(validateEntries({ entries, remote: REMOTE }), []); + assert.deepEqual(validateEntries({ entries, remote: { git_url: "ftp://x", ref: "-x", config: "../up.yml" } }), [ + "remote.git_url must be an https or ssh git URL", + "remote.ref must be a branch or tag name", + "remote.config must be a relative .yml path inside the remote repository", + ]); + }); + + test("plan returns the remotes: entry and lefthook install, and writes nothing", () => { + const root = remotePlugin(); + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), "# project hooks\n"); + const before = tree(dir); + const planned = run(root, dir, "plan", "--scope", "remote").json(); + assert.equal(planned.manager, "lefthook"); + assert.deepEqual(planned.actions, []); + assert.deepEqual(planned.decisions.find((d) => d.kind === "lefthook-config"), { + kind: "lefthook-config", + file: "lefthook.yml", + snippet: REMOTE_SNIPPET, + }); + assert.ok(kinds(planned).includes("lefthook-install")); + assert.deepEqual(tree(dir), before); + }); + + test("a repository with no hook manager is planned for lefthook, creating lefthook.yml", () => { + const planned = run(remotePlugin(), repo(), "plan", "--scope", "remote").json(); + assert.equal(planned.manager, "lefthook"); + assert.equal(planned.decisions.find((d) => d.kind === "lefthook-config").file, "lefthook.yml"); + }); + + test("husky is a remote-needs-lefthook decision", () => { + const dir = repo(); + mkdirSync(path.join(dir, ".husky")); + const planned = run(remotePlugin(), dir, "plan", "--scope", "remote").json(); + assert.deepEqual(kinds(planned), ["remote-needs-lefthook"]); + }); + + test("verify is missing until lefthook installed the hook and fetched the remote, then ok, and the probe is silent", () => { + const root = remotePlugin(); + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), `${REMOTE_SNIPPET}\n`); + const unfetched = run(root, dir, "verify"); + assert.equal(unfetched.code, 1); + assert.deepEqual(unfetched.out, [ + "missing\tword-check\tlefthook remote not fetched or hook not installed; run lefthook install", + ]); + fetched(dir); + const ok = run(root, dir, "verify"); + assert.equal(ok.code, 0); + assert.deepEqual(ok.out, ["ok\tword-check"]); + assert.deepEqual(run(root, dir, "plan", "--scope", "remote").json().decisions.filter((d) => d.kind.startsWith("lefthook")), []); + const out = []; + const env = { ...baseEnv, CLAUDE_PROJECT_DIR: dir }; + prerequisites(["probe", root], { env, cwd: dir, stdin: () => "{}" }, { stdout: (s) => out.push(s), stderr: () => {} }); + assert.deepEqual(out, []); + }); + + test("shared copies and their lefthook job are a migrate decision", () => { + const root = remotePlugin(); + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), "# project hooks\n"); + const shared = run(root, dir, "plan", "--scope", "shared").json().decisions.find((d) => d.kind === "lefthook-config"); + writeFileSync(path.join(dir, "lefthook.yml"), `${shared.snippet}\n`); + run(root, dir, "copy", "--scope", "shared"); + const migrate = run(root, dir, "plan", "--scope", "remote").json().decisions.find((d) => d.kind === "migrate"); + assert.deepEqual(migrate, { + kind: "migrate", + from: "shared", + dir: ".githooks/demo-checks", + files: [path.join(dir, ".githooks", "demo-checks", "word-check.sh")], + jobs: ["demo-checks-word-check"], + }); + }); + + test("copy --scope remote, and plan --scope remote for a plugin naming no remote, are usage errors", () => { + const dir = repo(); + assert.equal(run(remotePlugin(), dir, "copy", "--scope", "remote").code, 2); + const plain = run(plugin(), dir, "plan", "--scope", "remote"); + assert.equal(plain.code, 2); + assert.match(plain.err.join("\n"), /names no lefthook remote/); + }); +}); + describe("repository shapes", () => { test("linked-worktree: local copies land in the common hooks dir, and the exclude file is the common one", () => { const root = plugin(); diff --git a/plugins/actionlint/lib/git-hooks/install.mjs b/plugins/actionlint/lib/git-hooks/install.mjs index 756ca04a04..23b2b202aa 100755 --- a/plugins/actionlint/lib/git-hooks/install.mjs +++ b/plugins/actionlint/lib/git-hooks/install.mjs @@ -7,7 +7,7 @@ // // install.mjs detect [common] // install.mjs list [common] -// install.mjs plan --scope local|shared [--manager ] [common] +// install.mjs plan --scope local|shared|remote [--manager ] [common] // install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force] [common] // install.mjs decline --entry [--undo] [--global] [common] // install.mjs verify [--quiet] [common] @@ -22,6 +22,8 @@ // `git config --local` decline key. Everything else (edits to lefthook or husky // config, a foreign hook, a manager conflict, an exclude line, a migration, a // missing tool) comes back from plan as a decisions[] item for the setup agent. +// --scope remote, for a plugin whose entries.json names a lefthook remote, +// writes nothing: plan returns the `remotes:` entry for the lefthook config. // // Exit 0 done; 1 verify found drift, or copy refused a locally modified copy or // a target that is or passes through a symlink; @@ -190,12 +192,26 @@ function assetErrors(assets, where) { const assetName = (asset) => path.posix.basename(asset.replace(/\\/g, "/")); +// remote: optional { git_url, ref, config } naming a lefthook remote config, +// config being its path inside that repository. +function remoteErrors(remote) { + if (remote === undefined) return []; + const errors = []; + if (!/^(https:\/\/|git@)\S+$/.test(remote?.git_url ?? "")) errors.push("remote.git_url must be an https or ssh git URL"); + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(remote?.ref ?? "")) errors.push("remote.ref must be a branch or tag name"); + const config = remote?.config ?? ""; + if (!/\.ya?ml$/.test(config) || path.isAbsolute(config) || config.split(/[\\/]/).includes("..")) { + errors.push("remote.config must be a relative .yml path inside the remote repository"); + } + return errors; +} + // validateEntries(manifest) -> list of errors; empty means valid. export function validateEntries(manifest) { if (!Array.isArray(manifest?.entries) || manifest.entries.length === 0) { return ["entries.json needs a non-empty entries list"]; } - const errors = manifest.entries.flatMap(entryErrors); + const errors = [...manifest.entries.flatMap(entryErrors), ...remoteErrors(manifest.remote)]; const names = manifest.entries.map((e) => e?.name); if (new Set(names).size !== names.length) errors.push("entries.json names an entry twice"); return errors; @@ -232,7 +248,7 @@ export function loadPlugin(root) { if (errors.length) throw new UsageError(`${name}: ${errors.join("; ")}`); const libText = readText(path.join(root, "lib", "git-hooks", LIB_NAME)); if (libText === null) throw new UsageError(`${name}: lib/git-hooks/${LIB_NAME} is missing`); - return { name, root, entries: manifest.entries, libText }; + return { name, root, entries: manifest.entries, remote: manifest.remote, libText }; } // stampHash(script, lib, assets): sha256 over the LF-normalized script, a NUL, @@ -525,6 +541,66 @@ function migrateDecision(info, plugin, scope, manager) { return files.length ? [{ kind: "migrate", from: "local", files }] : []; } +// The plugin's lefthook remote: the `remotes:` entry, whether the lefthook +// config carries it, and whether lefthook fetched it, into +// /lefthook-remotes/-/ (lefthook 2.2.2). +const repoName = (url) => url.replace(/\.git$/, "").split(/[/:]/).pop(); +const repoSlug = (url) => url.replace(/\.git$/, "").split(/[/:]/).slice(-2).join("/"); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +function remoteConfigured(info, plugin) { + const text = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + return text.includes(repoSlug(plugin.remote.git_url)) && text.includes(plugin.remote.config); +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const dir = path.join(path.dirname(info.excludeFile), "lefthook-remotes", `${repoName(url)}-${ref}`); + return readText(path.join(dir, config)) !== null; +} + +const lefthookHook = (info, event) => Boolean(readText(path.join(info.hooksDir, event))?.includes("lefthook")); + +// The shared copies and their lefthook jobs that the remote replaces. +function sharedLeftovers(info, plugin) { + const files = plugin.entries.map((e) => entryPaths(info, plugin, e, "shared", "lefthook").script).filter((f) => existsSync(f)); + const config = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + const jobs = plugin.entries + .map((e) => `${plugin.name}-${e.name}`) + .filter((job) => new RegExp(`name:\\s*${job}\\s*$`, "m").test(config)); + if (!files.length && !jobs.length) return []; + return [{ kind: "migrate", from: "shared", dir: `${SHARED_DIR}/${plugin.name}`, files, jobs }]; +} + +function remotePlan(info, plugin, override) { + if (!plugin.remote) throw new UsageError(`${plugin.name} names no lefthook remote; use --scope local or shared`); + const found = detect(info); + const manager = override ?? (found.evidence.length ? found.manager : "lefthook"); + if (manager === "conflict") { + return { manager, actions: [], decisions: [{ kind: "manager-conflict", evidence: found.evidence }] }; + } + if (manager !== "lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-needs-lefthook", evidence: found.evidence }] }; + } + const decisions = []; + if (!remoteConfigured(info, plugin)) { + decisions.push({ kind: "lefthook-config", file: lefthookFile(info, "shared"), snippet: remoteSnippet(plugin.remote) }); + } + if (!plugin.entries.every((e) => lefthookHook(info, e.event)) || !remoteFetched(info, plugin)) { + decisions.push({ kind: "lefthook-install", command: "lefthook install" }); + } + decisions.push(...sharedLeftovers(info, plugin)); + for (const entry of plugin.entries) { + if (isDeclined(info, plugin, entry)) decisions.push({ kind: "declined", entry: entry.name }); + decisions.push(...toolDecisions(info, plugin, entry)); + } + return { manager, actions: [], decisions }; +} + // plan(info, plugin, scope, manager?) -> { manager, actions[], decisions[] } // A local install writes into `git rev-parse --git-path hooks`. With // core.hooksPath set to anything but the plain shared .githooks, that directory @@ -537,6 +613,7 @@ function hooksDirOwned(info, scope, manager) { } export function plan(info, plugin, scope, override) { + if (scope === "remote") return remotePlan(info, plugin, override); const found = detect(info); const manager = override ?? found.manager; if (manager === "conflict") { @@ -716,6 +793,10 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + if (plugin.remote && remoteConfigured(info, plugin)) { + if (lefthookHook(info, entry.event) && remoteFetched(info, plugin)) return { state: "ok" }; + return { state: "missing", detail: "lefthook remote not fetched or hook not installed; run lefthook install" }; + } const current = sourceHash(plugin, entry); const copies = list(info, plugin).filter((c) => c.entryId === entry.name); if (copies.length === 0) return { state: "missing", detail: "not installed" }; @@ -744,7 +825,7 @@ export function driftLine(plugin, row) { const USAGE = [ "usage: install.mjs detect|list [--repo ] [--plugin-root ]", - " install.mjs plan --scope local|shared [--manager lefthook|husky|plain]", + " install.mjs plan --scope local|shared|remote [--manager lefthook|husky|plain]", " install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force]", " install.mjs decline --entry [--undo] [--global]", " install.mjs verify [--quiet]", @@ -765,8 +846,11 @@ function parseArgs(argv, defaults) { else if (arg === "--entry" && rest[i + 1]) opts.entries.push(rest[++i]); else throw new UsageError(`unexpected argument: ${arg}`); } - if (["plan", "copy"].includes(action) && !["local", "shared"].includes(opts.scope)) { - throw new UsageError(`${action} needs --scope local or --scope shared`); + if (action === "plan" && !["local", "shared", "remote"].includes(opts.scope)) { + throw new UsageError("plan needs --scope local, shared or remote"); + } + if (action === "copy" && !["local", "shared"].includes(opts.scope)) { + throw new UsageError("copy needs --scope local or --scope shared; a remote install writes nothing, so apply plan's decisions"); } if (opts.manager && !MANAGERS.includes(opts.manager)) throw new UsageError(`unknown manager: ${opts.manager}`); if (action === "decline" && opts.entries.length !== 1) throw new UsageError("decline needs one --entry"); diff --git a/plugins/bash-format/lib/git-hooks/install.mjs b/plugins/bash-format/lib/git-hooks/install.mjs index 756ca04a04..23b2b202aa 100755 --- a/plugins/bash-format/lib/git-hooks/install.mjs +++ b/plugins/bash-format/lib/git-hooks/install.mjs @@ -7,7 +7,7 @@ // // install.mjs detect [common] // install.mjs list [common] -// install.mjs plan --scope local|shared [--manager ] [common] +// install.mjs plan --scope local|shared|remote [--manager ] [common] // install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force] [common] // install.mjs decline --entry [--undo] [--global] [common] // install.mjs verify [--quiet] [common] @@ -22,6 +22,8 @@ // `git config --local` decline key. Everything else (edits to lefthook or husky // config, a foreign hook, a manager conflict, an exclude line, a migration, a // missing tool) comes back from plan as a decisions[] item for the setup agent. +// --scope remote, for a plugin whose entries.json names a lefthook remote, +// writes nothing: plan returns the `remotes:` entry for the lefthook config. // // Exit 0 done; 1 verify found drift, or copy refused a locally modified copy or // a target that is or passes through a symlink; @@ -190,12 +192,26 @@ function assetErrors(assets, where) { const assetName = (asset) => path.posix.basename(asset.replace(/\\/g, "/")); +// remote: optional { git_url, ref, config } naming a lefthook remote config, +// config being its path inside that repository. +function remoteErrors(remote) { + if (remote === undefined) return []; + const errors = []; + if (!/^(https:\/\/|git@)\S+$/.test(remote?.git_url ?? "")) errors.push("remote.git_url must be an https or ssh git URL"); + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(remote?.ref ?? "")) errors.push("remote.ref must be a branch or tag name"); + const config = remote?.config ?? ""; + if (!/\.ya?ml$/.test(config) || path.isAbsolute(config) || config.split(/[\\/]/).includes("..")) { + errors.push("remote.config must be a relative .yml path inside the remote repository"); + } + return errors; +} + // validateEntries(manifest) -> list of errors; empty means valid. export function validateEntries(manifest) { if (!Array.isArray(manifest?.entries) || manifest.entries.length === 0) { return ["entries.json needs a non-empty entries list"]; } - const errors = manifest.entries.flatMap(entryErrors); + const errors = [...manifest.entries.flatMap(entryErrors), ...remoteErrors(manifest.remote)]; const names = manifest.entries.map((e) => e?.name); if (new Set(names).size !== names.length) errors.push("entries.json names an entry twice"); return errors; @@ -232,7 +248,7 @@ export function loadPlugin(root) { if (errors.length) throw new UsageError(`${name}: ${errors.join("; ")}`); const libText = readText(path.join(root, "lib", "git-hooks", LIB_NAME)); if (libText === null) throw new UsageError(`${name}: lib/git-hooks/${LIB_NAME} is missing`); - return { name, root, entries: manifest.entries, libText }; + return { name, root, entries: manifest.entries, remote: manifest.remote, libText }; } // stampHash(script, lib, assets): sha256 over the LF-normalized script, a NUL, @@ -525,6 +541,66 @@ function migrateDecision(info, plugin, scope, manager) { return files.length ? [{ kind: "migrate", from: "local", files }] : []; } +// The plugin's lefthook remote: the `remotes:` entry, whether the lefthook +// config carries it, and whether lefthook fetched it, into +// /lefthook-remotes/-/ (lefthook 2.2.2). +const repoName = (url) => url.replace(/\.git$/, "").split(/[/:]/).pop(); +const repoSlug = (url) => url.replace(/\.git$/, "").split(/[/:]/).slice(-2).join("/"); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +function remoteConfigured(info, plugin) { + const text = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + return text.includes(repoSlug(plugin.remote.git_url)) && text.includes(plugin.remote.config); +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const dir = path.join(path.dirname(info.excludeFile), "lefthook-remotes", `${repoName(url)}-${ref}`); + return readText(path.join(dir, config)) !== null; +} + +const lefthookHook = (info, event) => Boolean(readText(path.join(info.hooksDir, event))?.includes("lefthook")); + +// The shared copies and their lefthook jobs that the remote replaces. +function sharedLeftovers(info, plugin) { + const files = plugin.entries.map((e) => entryPaths(info, plugin, e, "shared", "lefthook").script).filter((f) => existsSync(f)); + const config = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + const jobs = plugin.entries + .map((e) => `${plugin.name}-${e.name}`) + .filter((job) => new RegExp(`name:\\s*${job}\\s*$`, "m").test(config)); + if (!files.length && !jobs.length) return []; + return [{ kind: "migrate", from: "shared", dir: `${SHARED_DIR}/${plugin.name}`, files, jobs }]; +} + +function remotePlan(info, plugin, override) { + if (!plugin.remote) throw new UsageError(`${plugin.name} names no lefthook remote; use --scope local or shared`); + const found = detect(info); + const manager = override ?? (found.evidence.length ? found.manager : "lefthook"); + if (manager === "conflict") { + return { manager, actions: [], decisions: [{ kind: "manager-conflict", evidence: found.evidence }] }; + } + if (manager !== "lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-needs-lefthook", evidence: found.evidence }] }; + } + const decisions = []; + if (!remoteConfigured(info, plugin)) { + decisions.push({ kind: "lefthook-config", file: lefthookFile(info, "shared"), snippet: remoteSnippet(plugin.remote) }); + } + if (!plugin.entries.every((e) => lefthookHook(info, e.event)) || !remoteFetched(info, plugin)) { + decisions.push({ kind: "lefthook-install", command: "lefthook install" }); + } + decisions.push(...sharedLeftovers(info, plugin)); + for (const entry of plugin.entries) { + if (isDeclined(info, plugin, entry)) decisions.push({ kind: "declined", entry: entry.name }); + decisions.push(...toolDecisions(info, plugin, entry)); + } + return { manager, actions: [], decisions }; +} + // plan(info, plugin, scope, manager?) -> { manager, actions[], decisions[] } // A local install writes into `git rev-parse --git-path hooks`. With // core.hooksPath set to anything but the plain shared .githooks, that directory @@ -537,6 +613,7 @@ function hooksDirOwned(info, scope, manager) { } export function plan(info, plugin, scope, override) { + if (scope === "remote") return remotePlan(info, plugin, override); const found = detect(info); const manager = override ?? found.manager; if (manager === "conflict") { @@ -716,6 +793,10 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + if (plugin.remote && remoteConfigured(info, plugin)) { + if (lefthookHook(info, entry.event) && remoteFetched(info, plugin)) return { state: "ok" }; + return { state: "missing", detail: "lefthook remote not fetched or hook not installed; run lefthook install" }; + } const current = sourceHash(plugin, entry); const copies = list(info, plugin).filter((c) => c.entryId === entry.name); if (copies.length === 0) return { state: "missing", detail: "not installed" }; @@ -744,7 +825,7 @@ export function driftLine(plugin, row) { const USAGE = [ "usage: install.mjs detect|list [--repo ] [--plugin-root ]", - " install.mjs plan --scope local|shared [--manager lefthook|husky|plain]", + " install.mjs plan --scope local|shared|remote [--manager lefthook|husky|plain]", " install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force]", " install.mjs decline --entry [--undo] [--global]", " install.mjs verify [--quiet]", @@ -765,8 +846,11 @@ function parseArgs(argv, defaults) { else if (arg === "--entry" && rest[i + 1]) opts.entries.push(rest[++i]); else throw new UsageError(`unexpected argument: ${arg}`); } - if (["plan", "copy"].includes(action) && !["local", "shared"].includes(opts.scope)) { - throw new UsageError(`${action} needs --scope local or --scope shared`); + if (action === "plan" && !["local", "shared", "remote"].includes(opts.scope)) { + throw new UsageError("plan needs --scope local, shared or remote"); + } + if (action === "copy" && !["local", "shared"].includes(opts.scope)) { + throw new UsageError("copy needs --scope local or --scope shared; a remote install writes nothing, so apply plan's decisions"); } if (opts.manager && !MANAGERS.includes(opts.manager)) throw new UsageError(`unknown manager: ${opts.manager}`); if (action === "decline" && opts.entries.length !== 1) throw new UsageError("decline needs one --entry"); diff --git a/plugins/biome-format/lib/git-hooks/install.mjs b/plugins/biome-format/lib/git-hooks/install.mjs index 756ca04a04..23b2b202aa 100755 --- a/plugins/biome-format/lib/git-hooks/install.mjs +++ b/plugins/biome-format/lib/git-hooks/install.mjs @@ -7,7 +7,7 @@ // // install.mjs detect [common] // install.mjs list [common] -// install.mjs plan --scope local|shared [--manager ] [common] +// install.mjs plan --scope local|shared|remote [--manager ] [common] // install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force] [common] // install.mjs decline --entry [--undo] [--global] [common] // install.mjs verify [--quiet] [common] @@ -22,6 +22,8 @@ // `git config --local` decline key. Everything else (edits to lefthook or husky // config, a foreign hook, a manager conflict, an exclude line, a migration, a // missing tool) comes back from plan as a decisions[] item for the setup agent. +// --scope remote, for a plugin whose entries.json names a lefthook remote, +// writes nothing: plan returns the `remotes:` entry for the lefthook config. // // Exit 0 done; 1 verify found drift, or copy refused a locally modified copy or // a target that is or passes through a symlink; @@ -190,12 +192,26 @@ function assetErrors(assets, where) { const assetName = (asset) => path.posix.basename(asset.replace(/\\/g, "/")); +// remote: optional { git_url, ref, config } naming a lefthook remote config, +// config being its path inside that repository. +function remoteErrors(remote) { + if (remote === undefined) return []; + const errors = []; + if (!/^(https:\/\/|git@)\S+$/.test(remote?.git_url ?? "")) errors.push("remote.git_url must be an https or ssh git URL"); + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(remote?.ref ?? "")) errors.push("remote.ref must be a branch or tag name"); + const config = remote?.config ?? ""; + if (!/\.ya?ml$/.test(config) || path.isAbsolute(config) || config.split(/[\\/]/).includes("..")) { + errors.push("remote.config must be a relative .yml path inside the remote repository"); + } + return errors; +} + // validateEntries(manifest) -> list of errors; empty means valid. export function validateEntries(manifest) { if (!Array.isArray(manifest?.entries) || manifest.entries.length === 0) { return ["entries.json needs a non-empty entries list"]; } - const errors = manifest.entries.flatMap(entryErrors); + const errors = [...manifest.entries.flatMap(entryErrors), ...remoteErrors(manifest.remote)]; const names = manifest.entries.map((e) => e?.name); if (new Set(names).size !== names.length) errors.push("entries.json names an entry twice"); return errors; @@ -232,7 +248,7 @@ export function loadPlugin(root) { if (errors.length) throw new UsageError(`${name}: ${errors.join("; ")}`); const libText = readText(path.join(root, "lib", "git-hooks", LIB_NAME)); if (libText === null) throw new UsageError(`${name}: lib/git-hooks/${LIB_NAME} is missing`); - return { name, root, entries: manifest.entries, libText }; + return { name, root, entries: manifest.entries, remote: manifest.remote, libText }; } // stampHash(script, lib, assets): sha256 over the LF-normalized script, a NUL, @@ -525,6 +541,66 @@ function migrateDecision(info, plugin, scope, manager) { return files.length ? [{ kind: "migrate", from: "local", files }] : []; } +// The plugin's lefthook remote: the `remotes:` entry, whether the lefthook +// config carries it, and whether lefthook fetched it, into +// /lefthook-remotes/-/ (lefthook 2.2.2). +const repoName = (url) => url.replace(/\.git$/, "").split(/[/:]/).pop(); +const repoSlug = (url) => url.replace(/\.git$/, "").split(/[/:]/).slice(-2).join("/"); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +function remoteConfigured(info, plugin) { + const text = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + return text.includes(repoSlug(plugin.remote.git_url)) && text.includes(plugin.remote.config); +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const dir = path.join(path.dirname(info.excludeFile), "lefthook-remotes", `${repoName(url)}-${ref}`); + return readText(path.join(dir, config)) !== null; +} + +const lefthookHook = (info, event) => Boolean(readText(path.join(info.hooksDir, event))?.includes("lefthook")); + +// The shared copies and their lefthook jobs that the remote replaces. +function sharedLeftovers(info, plugin) { + const files = plugin.entries.map((e) => entryPaths(info, plugin, e, "shared", "lefthook").script).filter((f) => existsSync(f)); + const config = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + const jobs = plugin.entries + .map((e) => `${plugin.name}-${e.name}`) + .filter((job) => new RegExp(`name:\\s*${job}\\s*$`, "m").test(config)); + if (!files.length && !jobs.length) return []; + return [{ kind: "migrate", from: "shared", dir: `${SHARED_DIR}/${plugin.name}`, files, jobs }]; +} + +function remotePlan(info, plugin, override) { + if (!plugin.remote) throw new UsageError(`${plugin.name} names no lefthook remote; use --scope local or shared`); + const found = detect(info); + const manager = override ?? (found.evidence.length ? found.manager : "lefthook"); + if (manager === "conflict") { + return { manager, actions: [], decisions: [{ kind: "manager-conflict", evidence: found.evidence }] }; + } + if (manager !== "lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-needs-lefthook", evidence: found.evidence }] }; + } + const decisions = []; + if (!remoteConfigured(info, plugin)) { + decisions.push({ kind: "lefthook-config", file: lefthookFile(info, "shared"), snippet: remoteSnippet(plugin.remote) }); + } + if (!plugin.entries.every((e) => lefthookHook(info, e.event)) || !remoteFetched(info, plugin)) { + decisions.push({ kind: "lefthook-install", command: "lefthook install" }); + } + decisions.push(...sharedLeftovers(info, plugin)); + for (const entry of plugin.entries) { + if (isDeclined(info, plugin, entry)) decisions.push({ kind: "declined", entry: entry.name }); + decisions.push(...toolDecisions(info, plugin, entry)); + } + return { manager, actions: [], decisions }; +} + // plan(info, plugin, scope, manager?) -> { manager, actions[], decisions[] } // A local install writes into `git rev-parse --git-path hooks`. With // core.hooksPath set to anything but the plain shared .githooks, that directory @@ -537,6 +613,7 @@ function hooksDirOwned(info, scope, manager) { } export function plan(info, plugin, scope, override) { + if (scope === "remote") return remotePlan(info, plugin, override); const found = detect(info); const manager = override ?? found.manager; if (manager === "conflict") { @@ -716,6 +793,10 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + if (plugin.remote && remoteConfigured(info, plugin)) { + if (lefthookHook(info, entry.event) && remoteFetched(info, plugin)) return { state: "ok" }; + return { state: "missing", detail: "lefthook remote not fetched or hook not installed; run lefthook install" }; + } const current = sourceHash(plugin, entry); const copies = list(info, plugin).filter((c) => c.entryId === entry.name); if (copies.length === 0) return { state: "missing", detail: "not installed" }; @@ -744,7 +825,7 @@ export function driftLine(plugin, row) { const USAGE = [ "usage: install.mjs detect|list [--repo ] [--plugin-root ]", - " install.mjs plan --scope local|shared [--manager lefthook|husky|plain]", + " install.mjs plan --scope local|shared|remote [--manager lefthook|husky|plain]", " install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force]", " install.mjs decline --entry [--undo] [--global]", " install.mjs verify [--quiet]", @@ -765,8 +846,11 @@ function parseArgs(argv, defaults) { else if (arg === "--entry" && rest[i + 1]) opts.entries.push(rest[++i]); else throw new UsageError(`unexpected argument: ${arg}`); } - if (["plan", "copy"].includes(action) && !["local", "shared"].includes(opts.scope)) { - throw new UsageError(`${action} needs --scope local or --scope shared`); + if (action === "plan" && !["local", "shared", "remote"].includes(opts.scope)) { + throw new UsageError("plan needs --scope local, shared or remote"); + } + if (action === "copy" && !["local", "shared"].includes(opts.scope)) { + throw new UsageError("copy needs --scope local or --scope shared; a remote install writes nothing, so apply plan's decisions"); } if (opts.manager && !MANAGERS.includes(opts.manager)) throw new UsageError(`unknown manager: ${opts.manager}`); if (action === "decline" && opts.entries.length !== 1) throw new UsageError("decline needs one --entry"); diff --git a/plugins/go-format/lib/git-hooks/install.mjs b/plugins/go-format/lib/git-hooks/install.mjs index 756ca04a04..23b2b202aa 100755 --- a/plugins/go-format/lib/git-hooks/install.mjs +++ b/plugins/go-format/lib/git-hooks/install.mjs @@ -7,7 +7,7 @@ // // install.mjs detect [common] // install.mjs list [common] -// install.mjs plan --scope local|shared [--manager ] [common] +// install.mjs plan --scope local|shared|remote [--manager ] [common] // install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force] [common] // install.mjs decline --entry [--undo] [--global] [common] // install.mjs verify [--quiet] [common] @@ -22,6 +22,8 @@ // `git config --local` decline key. Everything else (edits to lefthook or husky // config, a foreign hook, a manager conflict, an exclude line, a migration, a // missing tool) comes back from plan as a decisions[] item for the setup agent. +// --scope remote, for a plugin whose entries.json names a lefthook remote, +// writes nothing: plan returns the `remotes:` entry for the lefthook config. // // Exit 0 done; 1 verify found drift, or copy refused a locally modified copy or // a target that is or passes through a symlink; @@ -190,12 +192,26 @@ function assetErrors(assets, where) { const assetName = (asset) => path.posix.basename(asset.replace(/\\/g, "/")); +// remote: optional { git_url, ref, config } naming a lefthook remote config, +// config being its path inside that repository. +function remoteErrors(remote) { + if (remote === undefined) return []; + const errors = []; + if (!/^(https:\/\/|git@)\S+$/.test(remote?.git_url ?? "")) errors.push("remote.git_url must be an https or ssh git URL"); + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(remote?.ref ?? "")) errors.push("remote.ref must be a branch or tag name"); + const config = remote?.config ?? ""; + if (!/\.ya?ml$/.test(config) || path.isAbsolute(config) || config.split(/[\\/]/).includes("..")) { + errors.push("remote.config must be a relative .yml path inside the remote repository"); + } + return errors; +} + // validateEntries(manifest) -> list of errors; empty means valid. export function validateEntries(manifest) { if (!Array.isArray(manifest?.entries) || manifest.entries.length === 0) { return ["entries.json needs a non-empty entries list"]; } - const errors = manifest.entries.flatMap(entryErrors); + const errors = [...manifest.entries.flatMap(entryErrors), ...remoteErrors(manifest.remote)]; const names = manifest.entries.map((e) => e?.name); if (new Set(names).size !== names.length) errors.push("entries.json names an entry twice"); return errors; @@ -232,7 +248,7 @@ export function loadPlugin(root) { if (errors.length) throw new UsageError(`${name}: ${errors.join("; ")}`); const libText = readText(path.join(root, "lib", "git-hooks", LIB_NAME)); if (libText === null) throw new UsageError(`${name}: lib/git-hooks/${LIB_NAME} is missing`); - return { name, root, entries: manifest.entries, libText }; + return { name, root, entries: manifest.entries, remote: manifest.remote, libText }; } // stampHash(script, lib, assets): sha256 over the LF-normalized script, a NUL, @@ -525,6 +541,66 @@ function migrateDecision(info, plugin, scope, manager) { return files.length ? [{ kind: "migrate", from: "local", files }] : []; } +// The plugin's lefthook remote: the `remotes:` entry, whether the lefthook +// config carries it, and whether lefthook fetched it, into +// /lefthook-remotes/-/ (lefthook 2.2.2). +const repoName = (url) => url.replace(/\.git$/, "").split(/[/:]/).pop(); +const repoSlug = (url) => url.replace(/\.git$/, "").split(/[/:]/).slice(-2).join("/"); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +function remoteConfigured(info, plugin) { + const text = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + return text.includes(repoSlug(plugin.remote.git_url)) && text.includes(plugin.remote.config); +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const dir = path.join(path.dirname(info.excludeFile), "lefthook-remotes", `${repoName(url)}-${ref}`); + return readText(path.join(dir, config)) !== null; +} + +const lefthookHook = (info, event) => Boolean(readText(path.join(info.hooksDir, event))?.includes("lefthook")); + +// The shared copies and their lefthook jobs that the remote replaces. +function sharedLeftovers(info, plugin) { + const files = plugin.entries.map((e) => entryPaths(info, plugin, e, "shared", "lefthook").script).filter((f) => existsSync(f)); + const config = readText(path.join(info.top, lefthookFile(info, "shared"))) ?? ""; + const jobs = plugin.entries + .map((e) => `${plugin.name}-${e.name}`) + .filter((job) => new RegExp(`name:\\s*${job}\\s*$`, "m").test(config)); + if (!files.length && !jobs.length) return []; + return [{ kind: "migrate", from: "shared", dir: `${SHARED_DIR}/${plugin.name}`, files, jobs }]; +} + +function remotePlan(info, plugin, override) { + if (!plugin.remote) throw new UsageError(`${plugin.name} names no lefthook remote; use --scope local or shared`); + const found = detect(info); + const manager = override ?? (found.evidence.length ? found.manager : "lefthook"); + if (manager === "conflict") { + return { manager, actions: [], decisions: [{ kind: "manager-conflict", evidence: found.evidence }] }; + } + if (manager !== "lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-needs-lefthook", evidence: found.evidence }] }; + } + const decisions = []; + if (!remoteConfigured(info, plugin)) { + decisions.push({ kind: "lefthook-config", file: lefthookFile(info, "shared"), snippet: remoteSnippet(plugin.remote) }); + } + if (!plugin.entries.every((e) => lefthookHook(info, e.event)) || !remoteFetched(info, plugin)) { + decisions.push({ kind: "lefthook-install", command: "lefthook install" }); + } + decisions.push(...sharedLeftovers(info, plugin)); + for (const entry of plugin.entries) { + if (isDeclined(info, plugin, entry)) decisions.push({ kind: "declined", entry: entry.name }); + decisions.push(...toolDecisions(info, plugin, entry)); + } + return { manager, actions: [], decisions }; +} + // plan(info, plugin, scope, manager?) -> { manager, actions[], decisions[] } // A local install writes into `git rev-parse --git-path hooks`. With // core.hooksPath set to anything but the plain shared .githooks, that directory @@ -537,6 +613,7 @@ function hooksDirOwned(info, scope, manager) { } export function plan(info, plugin, scope, override) { + if (scope === "remote") return remotePlan(info, plugin, override); const found = detect(info); const manager = override ?? found.manager; if (manager === "conflict") { @@ -716,6 +793,10 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + if (plugin.remote && remoteConfigured(info, plugin)) { + if (lefthookHook(info, entry.event) && remoteFetched(info, plugin)) return { state: "ok" }; + return { state: "missing", detail: "lefthook remote not fetched or hook not installed; run lefthook install" }; + } const current = sourceHash(plugin, entry); const copies = list(info, plugin).filter((c) => c.entryId === entry.name); if (copies.length === 0) return { state: "missing", detail: "not installed" }; @@ -744,7 +825,7 @@ export function driftLine(plugin, row) { const USAGE = [ "usage: install.mjs detect|list [--repo ] [--plugin-root ]", - " install.mjs plan --scope local|shared [--manager lefthook|husky|plain]", + " install.mjs plan --scope local|shared|remote [--manager lefthook|husky|plain]", " install.mjs copy --scope local|shared [--entry ]... [--manager ] [--force]", " install.mjs decline --entry [--undo] [--global]", " install.mjs verify [--quiet]", @@ -765,8 +846,11 @@ function parseArgs(argv, defaults) { else if (arg === "--entry" && rest[i + 1]) opts.entries.push(rest[++i]); else throw new UsageError(`unexpected argument: ${arg}`); } - if (["plan", "copy"].includes(action) && !["local", "shared"].includes(opts.scope)) { - throw new UsageError(`${action} needs --scope local or --scope shared`); + if (action === "plan" && !["local", "shared", "remote"].includes(opts.scope)) { + throw new UsageError("plan needs --scope local, shared or remote"); + } + if (action === "copy" && !["local", "shared"].includes(opts.scope)) { + throw new UsageError("copy needs --scope local or --scope shared; a remote install writes nothing, so apply plan's decisions"); } if (opts.manager && !MANAGERS.includes(opts.manager)) throw new UsageError(`unknown manager: ${opts.manager}`); if (action === "decline" && opts.entries.length !== 1) throw new UsageError("decline needs one --entry"); diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 983c9fb9f7..caa0f00229 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -109,7 +109,10 @@ push time rather than after each edit. `/guardrails:setup apply install-git-hook registers them with the repository's hook manager (lefthook, husky or plain git hooks) under the check-placement convention's contract (`docs/conventions/check-placement/README.md` in the marketplace repository): local -scope is this clone only, shared scope commits copies under `.githooks/guardrails/`. +scope is this clone only. Shared scope with lefthook adds one `remotes:` entry to the +committed `lefthook.yml`, so every clone runs the hooks from this marketplace repository's +`main`, refetched daily (`git-hooks/lefthook-remote.yml`); with husky or plain hooks it commits +copies under `.githooks/guardrails/`. Each entry blocks only on what the commit or push adds. A clone that does not want an entry declines it through `/guardrails:setup` (`install.mjs decline --entry `), which also removes the entry's registration when it is already installed. diff --git a/plugins/guardrails/git-hooks/entries.json b/plugins/guardrails/git-hooks/entries.json index 10633c2518..7dac665c09 100644 --- a/plugins/guardrails/git-hooks/entries.json +++ b/plugins/guardrails/git-hooks/entries.json @@ -1,4 +1,9 @@ { + "remote": { + "git_url": "https://github.com/melodic-software/claude-code-plugins", + "ref": "main", + "config": "plugins/guardrails/git-hooks/lefthook-remote.yml" + }, "entries": [ { "name": "commit-msg-convention", diff --git a/plugins/guardrails/git-hooks/lefthook-remote.test.sh b/plugins/guardrails/git-hooks/lefthook-remote.test.sh new file mode 100755 index 0000000000..b92515aa24 --- /dev/null +++ b/plugins/guardrails/git-hooks/lefthook-remote.test.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Contract tests for the guardrails lefthook remote (git-hooks/lefthook-remote.yml): every job +# names a script under the repository root's .lefthook//, where lefthook looks up a +# remote's scripts, and that script runs an entry script that exists; no job takes a shared-copy +# job name (guardrails-), which lefthook would merge into one broken job; entries.json's +# remote names this file. With lefthook on PATH, a consumer whose lefthook.yml carries the +# snippet `install.mjs plan --scope remote` returns, fetched from a local stand-in for this +# repository, verifies ok and blocks an off-convention subject, a staged secret and (with jq) +# a pushed citation of a deleted path, reading the consumer's own files. +# test-scope: plugins/guardrails/git-hooks/lefthook-remote.yml plugins/guardrails/git-hooks/entries.json plugins/guardrails/lib/git-hooks/install.mjs plugins/guardrails/git-hooks/commit-msg-convention.sh plugins/guardrails/git-hooks/pre-commit-content.sh plugins/guardrails/git-hooks/reference-verify.sh .lefthook/commit-msg/guardrails-commit-msg-convention.sh .lefthook/pre-commit/guardrails-pre-commit-content.sh .lefthook/pre-push/guardrails-reference-verify.sh +set -uo pipefail +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_CONFIG CLAUDECODE AI_AGENT CLAUDE_PLUGIN_ROOT CLAUDE_PLUGIN_DATA LEFTHOOK + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN="$(cd "$HERE/.." && pwd)" +TOP="$(cd "$PLUGIN/../.." && pwd)" +CONFIG="$HERE/lefthook-remote.yml" +INSTALL="$PLUGIN/lib/git-hooks/install.mjs" + +PASS=0 +FAIL=0 +expect() { #