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 00000000000..b91bdc90b13 --- /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 00000000000..b91bdc90b13 --- /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 00000000000..b91bdc90b13 --- /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 00000000000..b91bdc90b13 --- /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 00000000000..fae1e207f7f --- /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` branch and refetches them every 24 hours, so every clone runs the hooks as merged to `main`, 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, and so does a lefthook config that sets its own `source_dir` (a `remote-source-dir` decision), because lefthook then looks for the remote's scripts in a directory the fetched copy does not have. 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 00000000000..b91bdc90b13 --- /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 00000000000..b91bdc90b13 --- /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 00000000000..b91bdc90b13 --- /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 00000000000..b91bdc90b13 --- /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 00000000000..b91bdc90b13 --- /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/.github/scripts/pr-require-checks/install-lefthook.sh b/.github/scripts/pr-require-checks/install-lefthook.sh new file mode 100755 index 00000000000..fff9e651e44 --- /dev/null +++ b/.github/scripts/pr-require-checks/install-lefthook.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Install the pinned lefthook on a Linux x86_64 runner, checksum-verified, into ~/.local/bin and +# onto $GITHUB_PATH: the lefthook the guardrails remote's end-to-end test +# (plugins/guardrails/git-hooks/lefthook-remote.test.sh) runs in pr-require-checks.yml's +# test-bash lane. +# The version is the one the fleet runs; the hash is the release's published SHA-256 for +# lefthook__Linux_x86_64. A cached download under ~/.cache/ci-tools is reused and +# checked the same way. +set -euo pipefail + +LEFTHOOK_VERSION=2.2.2 +LEFTHOOK_SHA256=4fbf89eb7748c6aed84d833ee1a29edac48fae614468ca9a82896ec32f2800b6 + +asset="lefthook_${LEFTHOOK_VERSION}_Linux_x86_64" +mkdir -p "$HOME/.local/bin" "$HOME/.cache/ci-tools" +cached="$HOME/.cache/ci-tools/$asset" +[[ -f "$cached" ]] || curl -fSL --no-progress-meter --retry 3 --retry-all-errors -o "$cached" \ + "https://github.com/evilmartians/lefthook/releases/download/v$LEFTHOOK_VERSION/$asset" +cp "$cached" "$HOME/.local/bin/lefthook" +echo "$LEFTHOOK_SHA256 $HOME/.local/bin/lefthook" | sha256sum -c - +chmod +x "$HOME/.local/bin/lefthook" +[[ -z "${GITHUB_PATH:-}" ]] || echo "$HOME/.local/bin" >>"$GITHUB_PATH" +test "$("$HOME/.local/bin/lefthook" version)" = "$LEFTHOOK_VERSION" diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 0635b84fcd4..fb6162e9151 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -2589,7 +2589,7 @@ jobs: rcfile: .shellcheckrc # The eval-coverage gate's suite reads `shfmt --to-json` and exits 2 when # shfmt is missing, so this job installs the same v3.14.1 pin as - # check-skills. The shfmt and DuckDB downloads are cached by version (four + # check-skills. The shfmt, DuckDB and lefthook downloads are cached by version (four # red main runs in two weeks were these downloads failing), and a miss # retries transient failures. `--no-progress-meter` instead of `-s` keeps # curl's "Will retry" warning in the log. The SHA-256 check runs on a hit @@ -2599,7 +2599,7 @@ jobs: uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ci-tools - key: ci-tools-${{ runner.os }}-shfmt-v3.14.1-duckdb-v1.5.5 + key: ci-tools-${{ runner.os }}-shfmt-v3.14.1-duckdb-v1.5.5-lefthook-2.2.2 - name: Install pinned shfmt env: SHFMT_VERSION: v3.14.1 @@ -2689,6 +2689,17 @@ jobs: gunzip "$RUNNER_TEMP/duckdb/duckdb.gz" chmod +x "$RUNNER_TEMP/duckdb/duckdb" echo "$RUNNER_TEMP/duckdb" >> "$GITHUB_PATH" + # The guardrails remote's end-to-end suite runs real lefthook against a + # stand-in remote; a broken lefthook-remote.yml would block every + # consumer's commits, so it fails here instead of skipping + # (GUARDRAILS_REQUIRE_LEFTHOOK). + - name: Install pinned lefthook + run: | + if [[ "$(jq -r --arg leg "$LEG" '.[$leg] // ""' <<<"$LEG_PLAN")" != *lefthook* ]]; then + echo "No suite on this leg runs lefthook." + exit 0 + fi + bash .github/scripts/pr-require-checks/install-lefthook.sh - name: Save the pinned tool downloads if: github.ref == 'refs/heads/main' && steps.tools_cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -2710,6 +2721,8 @@ jobs: # The check-visual-parity pixel cases fail on a missing pixelmatch or pngjs here instead of skipping green. VISUAL_COMPARE_REQUIRE: '1' VISUAL_COMPARE_DEPS_DIR: .work + # The guardrails remote's end-to-end cases fail on a missing lefthook here instead of skipping green. + GUARDRAILS_REQUIRE_LEFTHOOK: '1' # On main (schedule, dispatch) a failing suite in # scripts/run-plugin-tests-advisory.txt warns instead of failing the # leg; pull requests and merge groups still fail on it. 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 00000000000..9a81039d987 --- /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 00000000000..ea4c932c886 --- /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 00000000000..a72ad2a5165 --- /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 efa9a6ca20a..dcd42a680f5 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,133 @@ 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/-/, where lefthook +// 2.2.2 writes each "-" of the ref as "--" and each "/" as "-" +// (RemoteDirectoryName in its internal/git/remote.go); an earlier lefthook used the ref as is. +const repoName = (url) => path.posix.basename(url.slice(0, url.length - path.posix.extname(url).length)); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +// The lefthook config's `remotes` entries as { git_url, ref, configs }: YAML +// list items in any key order, TOML [[remotes]] tables, or JSON. Comments are +// skipped, so a comment naming the URL is no entry. +function lefthookRemotes(file, text) { + const unquote = (s) => s.replace(/[ \t]+#.*$/, "").trim().replace(/^(["'])(.*)\1$/, "$2"); + if (/\.jsonc?$/.test(file)) { + try { + const remotes = JSON.parse(text.replace(/^[ \t]*\/\/.*$/gm, "")).remotes; + return Array.isArray(remotes) ? remotes : []; + } catch { + return []; + } + } + if (file.endsWith(".toml")) { + return text + .split(/^[ \t]*\[\[remotes\]\][ \t]*$/m) + .slice(1) + .map((table) => { + const body = table.split(/^[ \t]*\[/m)[0]; + const val = (key) => new RegExp(`^[ \\t]*${key}[ \\t]*=(.*)$`, "m").exec(body)?.[1]; + const list = /^[ \t]*configs[ \t]*=[ \t]*\[([^\]]*)\]/m.exec(body)?.[1] ?? ""; + const ref = val("ref"); + return { git_url: unquote(val("git_url") ?? ""), ref: ref && unquote(ref), configs: [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]) }; + }); + } + const block = /^remotes:[ \t]*(?:#.*)?\n((?:[ \t#-].*(?:\n|$)|\n)*)/m.exec(text)?.[1] ?? ""; + const indent = /^([ \t]*)-/m.exec(block)?.[1] ?? ""; + return block + .split(new RegExp(`^${indent}-[ \\t]+`, "m")) + .slice(1) + .map((raw) => { + // Block and flow forms alike: `key: value` lines, `{key: value, ...}`, `configs: [a, b]`. + const item = raw.replace(/^[ \t]*#.*$/gm, "").replace(/[ \t]+#.*$/gm, ""); + const val = (key) => { + const v = new RegExp(`(?:^|[\\s{,])${key}:[ \\t]*([^\\n,}]*)`).exec(item)?.[1]; + return v === undefined ? undefined : unquote(v); + }; + const flow = /(?:^|[\s{,])configs:[ \t]*\[([^\]]*)\]/.exec(item)?.[1].split(",") ?? []; + const listed = item.split("\n").map((l) => /^[ \t]*-[ \t]+(.*)$/.exec(l)?.[1]).filter((v) => v !== undefined); + return { git_url: val("git_url"), ref: val("ref"), configs: [...flow, ...listed].map(unquote).filter(Boolean) }; + }); +} + +// The ref of the remotes entry whose git_url is exactly the plugin's and whose +// configs name its config: "" when that entry names no ref, null when none does. +function remoteRef(info, plugin) { + const { git_url: url, config } = plugin.remote; + const file = lefthookFile(info, "shared"); + const entry = lefthookRemotes(file, readText(path.join(info.top, file)) ?? "").find( + (r) => r?.git_url === url && Array.isArray(r.configs) && r.configs.includes(config), + ); + return entry ? String(entry.ref ?? "") : null; +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const names = [ref.replaceAll("-", "--").replaceAll("/", "-"), ref]; + const root = path.join(path.dirname(info.excludeFile), "lefthook-remotes"); + const prefixes = names.map((name) => `${repoName(url)}-${name}`); + // A cache directory with a suffix after the ref counts too; another ref's, or + // another repository's whose name extends this one, does not. + const dirs = existsSync(root) ? readdirSync(root).filter((d) => prefixes.some((p) => d === p || d.startsWith(`${p}-`))) : []; + return dirs.some((d) => readText(path.join(root, d, 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}\\2|^\\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 }] }; + } + // Lefthook looks up a remote's scripts under the consumer's source_dir inside + // the fetched copy, so a source_dir other than the default finds none of them. + const file = lefthookFile(info, "shared"); + const text = readText(path.join(info.top, file)) ?? ""; + const sourceDir = /\.jsonc?$/.test(file) + ? /"source_dir"\s*:\s*"([^"]+)"/.exec(text)?.[1] + : /^source_dir[ \t]*[:=][ \t]*["']?([^"'\s#]+)/m.exec(text)?.[1]; + if (sourceDir && sourceDir.replace(/^\.\//, "").replace(/\/+$/, "") !== ".lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-source-dir", file, sourceDir }] }; + } + const decisions = []; + const ref = remoteRef(info, plugin); + if (ref === null) decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote) }); + else if (ref !== plugin.remote.ref) { + decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote), foundRef: ref }); + } + 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), ...migrateDecision(info, plugin, "shared", manager)); + 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 +678,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 +858,14 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + const ref = plugin.remote ? remoteRef(info, plugin) : null; + if (ref !== null) { + if (ref !== plugin.remote.ref) { + return { state: "missing", detail: `lefthook remote ref is "${ref}", not ${plugin.remote.ref}; set ref: ${plugin.remote.ref}` }; + } + 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 +894,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 +915,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 d23e051c12f..3b80f722f8f 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,246 @@ 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("a lefthook config with its own source_dir is a remote-source-dir decision; the default passes", () => { + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), "source_dir: .hooks/\n"); + const planned = run(remotePlugin(), dir, "plan", "--scope", "remote").json(); + assert.deepEqual(planned.decisions, [{ kind: "remote-source-dir", file: "lefthook.yml", sourceDir: ".hooks/" }]); + writeFileSync(path.join(dir, "lefthook.yml"), "source_dir: ./.lefthook/\n"); + assert.ok(kinds(run(remotePlugin(), dir, "plan", "--scope", "remote").json()).includes("lefthook-config")); + }); + + test("a source_dir in a TOML or JSON lefthook config is a remote-source-dir decision", () => { + for (const [file, text] of [["lefthook.toml", 'source_dir = ".hooks/"\n'], ["lefthook.json", '{"source_dir": ".hooks/"}']]) { + const dir = repo(); + writeFileSync(path.join(dir, file), text); + assert.deepEqual(run(remotePlugin(), dir, "plan", "--scope", "remote").json().decisions, [ + { kind: "remote-source-dir", file, sourceDir: ".hooks/" }, + ]); + } + }); + + 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("an entry on another ref is a lefthook-config decision naming it, and verify says to set the ref", () => { + const root = remotePlugin(); + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), `${REMOTE_SNIPPET.replace("ref: main", "ref: develop")}\n`); + fetched(dir); + const config = run(root, dir, "plan", "--scope", "remote").json().decisions.find((d) => d.kind === "lefthook-config"); + assert.equal(config.foundRef, "develop"); + const verify = run(root, dir, "verify"); + assert.equal(verify.code, 1); + assert.deepEqual(verify.out, [ + 'missing\tword-check\tlefthook remote ref is "develop", not main; set ref: main', + ]); + }); + + test("another host, or a comment naming the URL, is not the remote", () => { + const root = remotePlugin(); + const dir = repo(); + fetched(dir); + const other = REMOTE_SNIPPET.replace("https://example.invalid/", "https://evil.invalid/"); + for (const text of [other, `# ${REMOTE.git_url} ${REMOTE.config}`]) { + writeFileSync(path.join(dir, "lefthook.yml"), `${text}\n`); + assert.ok(kinds(run(root, dir, "plan", "--scope", "remote").json()).includes("lefthook-config")); + assert.notDeepEqual(run(root, dir, "verify").out, ["ok\tword-check"]); + } + }); + + test("the remote verifies ok in any key order, and in TOML and JSON configs", () => { + const root = remotePlugin(); + const { git_url: url, ref, config } = REMOTE; + const shapes = { + "lefthook.yml": `remotes: # shared\n- ref: "${ref}"\n configs: [x]\n configs:\n - ${config}\n git_url: ${url}\n`, + ".lefthook.yml": `remotes:\n - git_url: ${url}\n # ref: develop\n ref: ${ref}\n configs: [other.yml, "${config}"]\n`, + ".config/lefthook.yml": `remotes:\n - {git_url: ${url}, ref: ${ref}, configs: [${config}]}\n`, + "lefthook.toml": `[[remotes]]\ngit_url = "${url}"\nref = "${ref}"\nconfigs = ["${config}"]\n\n[pre-commit]\n`, + "lefthook.json": JSON.stringify({ remotes: [{ git_url: url, ref, configs: [config] }] }), + }; + for (const [file, text] of Object.entries(shapes)) { + const dir = repo(); + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), text); + fetched(dir); + assert.deepEqual(run(root, dir, "verify").out, ["ok\tword-check"], file); + assert.ok(!kinds(run(root, dir, "plan", "--scope", "remote").json()).includes("lefthook-config"), file); + } + }); + + test("a column-0 comment between remotes entries hides no entry", () => { + const root = remotePlugin(); + const dir = repo(); + const ours = REMOTE_SNIPPET.replace(/^remotes:\n/, ""); + writeFileSync(path.join(dir, "lefthook.yml"), `remotes:\n - git_url: https://example.invalid/other\n configs: [x.yml]\n# guardrails hooks\n${ours}\n`); + fetched(dir); + assert.deepEqual(run(root, dir, "verify").out, ["ok\tword-check"]); + assert.ok(!kinds(run(root, dir, "plan", "--scope", "remote").json()).includes("lefthook-config")); + }); + + test("a key named in a trailing comment does not shadow the real key", () => { + const root = remotePlugin(); + const dir = repo(); + const text = REMOTE_SNIPPET.replace(`git_url: ${REMOTE.git_url}`, `git_url: ${REMOTE.git_url} # pinned, ref: develop`); + writeFileSync(path.join(dir, "lefthook.yml"), `${text}\n`); + fetched(dir); + assert.deepEqual(run(root, dir, "verify").out, ["ok\tword-check"]); + }); + + test("a fetched copy under another directory name still verifies", () => { + const root = remotePlugin(); + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), `${REMOTE_SNIPPET}\n`); + 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-0123abc", REMOTE.config); + mkdirSync(path.dirname(config), { recursive: true }); + writeFileSync(config, "pre-commit:\n"); + assert.deepEqual(run(root, dir, "verify").out, ["ok\tword-check"]); + }); + + test("a fetched copy of another ref, or of a repository whose name extends this one, stays missing", () => { + const root = remotePlugin(); + for (const name of ["checks-repo-develop", "checks-repo-extra-main"]) { + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), `${REMOTE_SNIPPET}\n`); + 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", name, REMOTE.config); + mkdirSync(path.dirname(config), { recursive: true }); + writeFileSync(config, "pre-commit:\n"); + assert.equal(run(root, dir, "verify").code, 1, name); + } + }); + + test("a quoted job name and a commands: key are both leftover shared jobs", () => { + const root = remotePlugin(); + const dir = repo(); + for (const config of ['pre-commit:\n jobs:\n - name: "demo-checks-word-check"\n', "pre-commit:\n commands:\n demo-checks-word-check:\n run: x\n"]) { + writeFileSync(path.join(dir, "lefthook.yml"), config); + const migrate = run(root, dir, "plan", "--scope", "remote").json().decisions.find((d) => d.kind === "migrate"); + assert.deepEqual(migrate?.jobs, ["demo-checks-word-check"]); + } + }); + + test("local copies are a migrate decision in a remote plan", () => { + const root = remotePlugin(); + const dir = repo(); + writeFileSync(path.join(dir, "lefthook.yml"), "# project hooks\n"); + run(root, dir, "copy", "--scope", "local"); + const migrate = run(root, dir, "plan", "--scope", "remote").json().decisions.find((d) => d.kind === "migrate" && d.from === "local"); + assert.equal(migrate?.files.length, 1); + }); + + 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 756ca04a046..00af9ed72a1 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,133 @@ 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/-/, where lefthook +// 2.2.2 writes each "-" of the ref as "--" and each "/" as "-" +// (RemoteDirectoryName in its internal/git/remote.go); an earlier lefthook used the ref as is. +const repoName = (url) => path.posix.basename(url.slice(0, url.length - path.posix.extname(url).length)); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +// The lefthook config's `remotes` entries as { git_url, ref, configs }: YAML +// list items in any key order, TOML [[remotes]] tables, or JSON. Comments are +// skipped, so a comment naming the URL is no entry. +function lefthookRemotes(file, text) { + const unquote = (s) => s.replace(/[ \t]+#.*$/, "").trim().replace(/^(["'])(.*)\1$/, "$2"); + if (/\.jsonc?$/.test(file)) { + try { + const remotes = JSON.parse(text.replace(/^[ \t]*\/\/.*$/gm, "")).remotes; + return Array.isArray(remotes) ? remotes : []; + } catch { + return []; + } + } + if (file.endsWith(".toml")) { + return text + .split(/^[ \t]*\[\[remotes\]\][ \t]*$/m) + .slice(1) + .map((table) => { + const body = table.split(/^[ \t]*\[/m)[0]; + const val = (key) => new RegExp(`^[ \\t]*${key}[ \\t]*=(.*)$`, "m").exec(body)?.[1]; + const list = /^[ \t]*configs[ \t]*=[ \t]*\[([^\]]*)\]/m.exec(body)?.[1] ?? ""; + const ref = val("ref"); + return { git_url: unquote(val("git_url") ?? ""), ref: ref && unquote(ref), configs: [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]) }; + }); + } + const block = /^remotes:[ \t]*(?:#.*)?\n((?:[ \t#-].*(?:\n|$)|\n)*)/m.exec(text)?.[1] ?? ""; + const indent = /^([ \t]*)-/m.exec(block)?.[1] ?? ""; + return block + .split(new RegExp(`^${indent}-[ \\t]+`, "m")) + .slice(1) + .map((raw) => { + // Block and flow forms alike: `key: value` lines, `{key: value, ...}`, `configs: [a, b]`. + const item = raw.replace(/^[ \t]*#.*$/gm, "").replace(/[ \t]+#.*$/gm, ""); + const val = (key) => { + const v = new RegExp(`(?:^|[\\s{,])${key}:[ \\t]*([^\\n,}]*)`).exec(item)?.[1]; + return v === undefined ? undefined : unquote(v); + }; + const flow = /(?:^|[\s{,])configs:[ \t]*\[([^\]]*)\]/.exec(item)?.[1].split(",") ?? []; + const listed = item.split("\n").map((l) => /^[ \t]*-[ \t]+(.*)$/.exec(l)?.[1]).filter((v) => v !== undefined); + return { git_url: val("git_url"), ref: val("ref"), configs: [...flow, ...listed].map(unquote).filter(Boolean) }; + }); +} + +// The ref of the remotes entry whose git_url is exactly the plugin's and whose +// configs name its config: "" when that entry names no ref, null when none does. +function remoteRef(info, plugin) { + const { git_url: url, config } = plugin.remote; + const file = lefthookFile(info, "shared"); + const entry = lefthookRemotes(file, readText(path.join(info.top, file)) ?? "").find( + (r) => r?.git_url === url && Array.isArray(r.configs) && r.configs.includes(config), + ); + return entry ? String(entry.ref ?? "") : null; +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const names = [ref.replaceAll("-", "--").replaceAll("/", "-"), ref]; + const root = path.join(path.dirname(info.excludeFile), "lefthook-remotes"); + const prefixes = names.map((name) => `${repoName(url)}-${name}`); + // A cache directory with a suffix after the ref counts too; another ref's, or + // another repository's whose name extends this one, does not. + const dirs = existsSync(root) ? readdirSync(root).filter((d) => prefixes.some((p) => d === p || d.startsWith(`${p}-`))) : []; + return dirs.some((d) => readText(path.join(root, d, 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}\\2|^\\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 }] }; + } + // Lefthook looks up a remote's scripts under the consumer's source_dir inside + // the fetched copy, so a source_dir other than the default finds none of them. + const file = lefthookFile(info, "shared"); + const text = readText(path.join(info.top, file)) ?? ""; + const sourceDir = /\.jsonc?$/.test(file) + ? /"source_dir"\s*:\s*"([^"]+)"/.exec(text)?.[1] + : /^source_dir[ \t]*[:=][ \t]*["']?([^"'\s#]+)/m.exec(text)?.[1]; + if (sourceDir && sourceDir.replace(/^\.\//, "").replace(/\/+$/, "") !== ".lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-source-dir", file, sourceDir }] }; + } + const decisions = []; + const ref = remoteRef(info, plugin); + if (ref === null) decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote) }); + else if (ref !== plugin.remote.ref) { + decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote), foundRef: ref }); + } + 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), ...migrateDecision(info, plugin, "shared", manager)); + 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 +680,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 +860,14 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + const ref = plugin.remote ? remoteRef(info, plugin) : null; + if (ref !== null) { + if (ref !== plugin.remote.ref) { + return { state: "missing", detail: `lefthook remote ref is "${ref}", not ${plugin.remote.ref}; set ref: ${plugin.remote.ref}` }; + } + 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 +896,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 +917,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 756ca04a046..00af9ed72a1 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,133 @@ 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/-/, where lefthook +// 2.2.2 writes each "-" of the ref as "--" and each "/" as "-" +// (RemoteDirectoryName in its internal/git/remote.go); an earlier lefthook used the ref as is. +const repoName = (url) => path.posix.basename(url.slice(0, url.length - path.posix.extname(url).length)); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +// The lefthook config's `remotes` entries as { git_url, ref, configs }: YAML +// list items in any key order, TOML [[remotes]] tables, or JSON. Comments are +// skipped, so a comment naming the URL is no entry. +function lefthookRemotes(file, text) { + const unquote = (s) => s.replace(/[ \t]+#.*$/, "").trim().replace(/^(["'])(.*)\1$/, "$2"); + if (/\.jsonc?$/.test(file)) { + try { + const remotes = JSON.parse(text.replace(/^[ \t]*\/\/.*$/gm, "")).remotes; + return Array.isArray(remotes) ? remotes : []; + } catch { + return []; + } + } + if (file.endsWith(".toml")) { + return text + .split(/^[ \t]*\[\[remotes\]\][ \t]*$/m) + .slice(1) + .map((table) => { + const body = table.split(/^[ \t]*\[/m)[0]; + const val = (key) => new RegExp(`^[ \\t]*${key}[ \\t]*=(.*)$`, "m").exec(body)?.[1]; + const list = /^[ \t]*configs[ \t]*=[ \t]*\[([^\]]*)\]/m.exec(body)?.[1] ?? ""; + const ref = val("ref"); + return { git_url: unquote(val("git_url") ?? ""), ref: ref && unquote(ref), configs: [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]) }; + }); + } + const block = /^remotes:[ \t]*(?:#.*)?\n((?:[ \t#-].*(?:\n|$)|\n)*)/m.exec(text)?.[1] ?? ""; + const indent = /^([ \t]*)-/m.exec(block)?.[1] ?? ""; + return block + .split(new RegExp(`^${indent}-[ \\t]+`, "m")) + .slice(1) + .map((raw) => { + // Block and flow forms alike: `key: value` lines, `{key: value, ...}`, `configs: [a, b]`. + const item = raw.replace(/^[ \t]*#.*$/gm, "").replace(/[ \t]+#.*$/gm, ""); + const val = (key) => { + const v = new RegExp(`(?:^|[\\s{,])${key}:[ \\t]*([^\\n,}]*)`).exec(item)?.[1]; + return v === undefined ? undefined : unquote(v); + }; + const flow = /(?:^|[\s{,])configs:[ \t]*\[([^\]]*)\]/.exec(item)?.[1].split(",") ?? []; + const listed = item.split("\n").map((l) => /^[ \t]*-[ \t]+(.*)$/.exec(l)?.[1]).filter((v) => v !== undefined); + return { git_url: val("git_url"), ref: val("ref"), configs: [...flow, ...listed].map(unquote).filter(Boolean) }; + }); +} + +// The ref of the remotes entry whose git_url is exactly the plugin's and whose +// configs name its config: "" when that entry names no ref, null when none does. +function remoteRef(info, plugin) { + const { git_url: url, config } = plugin.remote; + const file = lefthookFile(info, "shared"); + const entry = lefthookRemotes(file, readText(path.join(info.top, file)) ?? "").find( + (r) => r?.git_url === url && Array.isArray(r.configs) && r.configs.includes(config), + ); + return entry ? String(entry.ref ?? "") : null; +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const names = [ref.replaceAll("-", "--").replaceAll("/", "-"), ref]; + const root = path.join(path.dirname(info.excludeFile), "lefthook-remotes"); + const prefixes = names.map((name) => `${repoName(url)}-${name}`); + // A cache directory with a suffix after the ref counts too; another ref's, or + // another repository's whose name extends this one, does not. + const dirs = existsSync(root) ? readdirSync(root).filter((d) => prefixes.some((p) => d === p || d.startsWith(`${p}-`))) : []; + return dirs.some((d) => readText(path.join(root, d, 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}\\2|^\\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 }] }; + } + // Lefthook looks up a remote's scripts under the consumer's source_dir inside + // the fetched copy, so a source_dir other than the default finds none of them. + const file = lefthookFile(info, "shared"); + const text = readText(path.join(info.top, file)) ?? ""; + const sourceDir = /\.jsonc?$/.test(file) + ? /"source_dir"\s*:\s*"([^"]+)"/.exec(text)?.[1] + : /^source_dir[ \t]*[:=][ \t]*["']?([^"'\s#]+)/m.exec(text)?.[1]; + if (sourceDir && sourceDir.replace(/^\.\//, "").replace(/\/+$/, "") !== ".lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-source-dir", file, sourceDir }] }; + } + const decisions = []; + const ref = remoteRef(info, plugin); + if (ref === null) decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote) }); + else if (ref !== plugin.remote.ref) { + decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote), foundRef: ref }); + } + 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), ...migrateDecision(info, plugin, "shared", manager)); + 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 +680,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 +860,14 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + const ref = plugin.remote ? remoteRef(info, plugin) : null; + if (ref !== null) { + if (ref !== plugin.remote.ref) { + return { state: "missing", detail: `lefthook remote ref is "${ref}", not ${plugin.remote.ref}; set ref: ${plugin.remote.ref}` }; + } + 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 +896,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 +917,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 756ca04a046..00af9ed72a1 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,133 @@ 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/-/, where lefthook +// 2.2.2 writes each "-" of the ref as "--" and each "/" as "-" +// (RemoteDirectoryName in its internal/git/remote.go); an earlier lefthook used the ref as is. +const repoName = (url) => path.posix.basename(url.slice(0, url.length - path.posix.extname(url).length)); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +// The lefthook config's `remotes` entries as { git_url, ref, configs }: YAML +// list items in any key order, TOML [[remotes]] tables, or JSON. Comments are +// skipped, so a comment naming the URL is no entry. +function lefthookRemotes(file, text) { + const unquote = (s) => s.replace(/[ \t]+#.*$/, "").trim().replace(/^(["'])(.*)\1$/, "$2"); + if (/\.jsonc?$/.test(file)) { + try { + const remotes = JSON.parse(text.replace(/^[ \t]*\/\/.*$/gm, "")).remotes; + return Array.isArray(remotes) ? remotes : []; + } catch { + return []; + } + } + if (file.endsWith(".toml")) { + return text + .split(/^[ \t]*\[\[remotes\]\][ \t]*$/m) + .slice(1) + .map((table) => { + const body = table.split(/^[ \t]*\[/m)[0]; + const val = (key) => new RegExp(`^[ \\t]*${key}[ \\t]*=(.*)$`, "m").exec(body)?.[1]; + const list = /^[ \t]*configs[ \t]*=[ \t]*\[([^\]]*)\]/m.exec(body)?.[1] ?? ""; + const ref = val("ref"); + return { git_url: unquote(val("git_url") ?? ""), ref: ref && unquote(ref), configs: [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]) }; + }); + } + const block = /^remotes:[ \t]*(?:#.*)?\n((?:[ \t#-].*(?:\n|$)|\n)*)/m.exec(text)?.[1] ?? ""; + const indent = /^([ \t]*)-/m.exec(block)?.[1] ?? ""; + return block + .split(new RegExp(`^${indent}-[ \\t]+`, "m")) + .slice(1) + .map((raw) => { + // Block and flow forms alike: `key: value` lines, `{key: value, ...}`, `configs: [a, b]`. + const item = raw.replace(/^[ \t]*#.*$/gm, "").replace(/[ \t]+#.*$/gm, ""); + const val = (key) => { + const v = new RegExp(`(?:^|[\\s{,])${key}:[ \\t]*([^\\n,}]*)`).exec(item)?.[1]; + return v === undefined ? undefined : unquote(v); + }; + const flow = /(?:^|[\s{,])configs:[ \t]*\[([^\]]*)\]/.exec(item)?.[1].split(",") ?? []; + const listed = item.split("\n").map((l) => /^[ \t]*-[ \t]+(.*)$/.exec(l)?.[1]).filter((v) => v !== undefined); + return { git_url: val("git_url"), ref: val("ref"), configs: [...flow, ...listed].map(unquote).filter(Boolean) }; + }); +} + +// The ref of the remotes entry whose git_url is exactly the plugin's and whose +// configs name its config: "" when that entry names no ref, null when none does. +function remoteRef(info, plugin) { + const { git_url: url, config } = plugin.remote; + const file = lefthookFile(info, "shared"); + const entry = lefthookRemotes(file, readText(path.join(info.top, file)) ?? "").find( + (r) => r?.git_url === url && Array.isArray(r.configs) && r.configs.includes(config), + ); + return entry ? String(entry.ref ?? "") : null; +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const names = [ref.replaceAll("-", "--").replaceAll("/", "-"), ref]; + const root = path.join(path.dirname(info.excludeFile), "lefthook-remotes"); + const prefixes = names.map((name) => `${repoName(url)}-${name}`); + // A cache directory with a suffix after the ref counts too; another ref's, or + // another repository's whose name extends this one, does not. + const dirs = existsSync(root) ? readdirSync(root).filter((d) => prefixes.some((p) => d === p || d.startsWith(`${p}-`))) : []; + return dirs.some((d) => readText(path.join(root, d, 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}\\2|^\\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 }] }; + } + // Lefthook looks up a remote's scripts under the consumer's source_dir inside + // the fetched copy, so a source_dir other than the default finds none of them. + const file = lefthookFile(info, "shared"); + const text = readText(path.join(info.top, file)) ?? ""; + const sourceDir = /\.jsonc?$/.test(file) + ? /"source_dir"\s*:\s*"([^"]+)"/.exec(text)?.[1] + : /^source_dir[ \t]*[:=][ \t]*["']?([^"'\s#]+)/m.exec(text)?.[1]; + if (sourceDir && sourceDir.replace(/^\.\//, "").replace(/\/+$/, "") !== ".lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-source-dir", file, sourceDir }] }; + } + const decisions = []; + const ref = remoteRef(info, plugin); + if (ref === null) decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote) }); + else if (ref !== plugin.remote.ref) { + decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote), foundRef: ref }); + } + 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), ...migrateDecision(info, plugin, "shared", manager)); + 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 +680,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 +860,14 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + const ref = plugin.remote ? remoteRef(info, plugin) : null; + if (ref !== null) { + if (ref !== plugin.remote.ref) { + return { state: "missing", detail: `lefthook remote ref is "${ref}", not ${plugin.remote.ref}; set ref: ${plugin.remote.ref}` }; + } + 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 +896,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 +917,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 756ca04a046..00af9ed72a1 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,133 @@ 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/-/, where lefthook +// 2.2.2 writes each "-" of the ref as "--" and each "/" as "-" +// (RemoteDirectoryName in its internal/git/remote.go); an earlier lefthook used the ref as is. +const repoName = (url) => path.posix.basename(url.slice(0, url.length - path.posix.extname(url).length)); + +function remoteSnippet(remote) { + const lines = ["remotes:", ` - git_url: ${remote.git_url}`, ` ref: ${remote.ref}`, " refetch_frequency: 24h"]; + return [...lines, " configs:", ` - ${remote.config}`].join("\n"); +} + +// The lefthook config's `remotes` entries as { git_url, ref, configs }: YAML +// list items in any key order, TOML [[remotes]] tables, or JSON. Comments are +// skipped, so a comment naming the URL is no entry. +function lefthookRemotes(file, text) { + const unquote = (s) => s.replace(/[ \t]+#.*$/, "").trim().replace(/^(["'])(.*)\1$/, "$2"); + if (/\.jsonc?$/.test(file)) { + try { + const remotes = JSON.parse(text.replace(/^[ \t]*\/\/.*$/gm, "")).remotes; + return Array.isArray(remotes) ? remotes : []; + } catch { + return []; + } + } + if (file.endsWith(".toml")) { + return text + .split(/^[ \t]*\[\[remotes\]\][ \t]*$/m) + .slice(1) + .map((table) => { + const body = table.split(/^[ \t]*\[/m)[0]; + const val = (key) => new RegExp(`^[ \\t]*${key}[ \\t]*=(.*)$`, "m").exec(body)?.[1]; + const list = /^[ \t]*configs[ \t]*=[ \t]*\[([^\]]*)\]/m.exec(body)?.[1] ?? ""; + const ref = val("ref"); + return { git_url: unquote(val("git_url") ?? ""), ref: ref && unquote(ref), configs: [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]) }; + }); + } + const block = /^remotes:[ \t]*(?:#.*)?\n((?:[ \t#-].*(?:\n|$)|\n)*)/m.exec(text)?.[1] ?? ""; + const indent = /^([ \t]*)-/m.exec(block)?.[1] ?? ""; + return block + .split(new RegExp(`^${indent}-[ \\t]+`, "m")) + .slice(1) + .map((raw) => { + // Block and flow forms alike: `key: value` lines, `{key: value, ...}`, `configs: [a, b]`. + const item = raw.replace(/^[ \t]*#.*$/gm, "").replace(/[ \t]+#.*$/gm, ""); + const val = (key) => { + const v = new RegExp(`(?:^|[\\s{,])${key}:[ \\t]*([^\\n,}]*)`).exec(item)?.[1]; + return v === undefined ? undefined : unquote(v); + }; + const flow = /(?:^|[\s{,])configs:[ \t]*\[([^\]]*)\]/.exec(item)?.[1].split(",") ?? []; + const listed = item.split("\n").map((l) => /^[ \t]*-[ \t]+(.*)$/.exec(l)?.[1]).filter((v) => v !== undefined); + return { git_url: val("git_url"), ref: val("ref"), configs: [...flow, ...listed].map(unquote).filter(Boolean) }; + }); +} + +// The ref of the remotes entry whose git_url is exactly the plugin's and whose +// configs name its config: "" when that entry names no ref, null when none does. +function remoteRef(info, plugin) { + const { git_url: url, config } = plugin.remote; + const file = lefthookFile(info, "shared"); + const entry = lefthookRemotes(file, readText(path.join(info.top, file)) ?? "").find( + (r) => r?.git_url === url && Array.isArray(r.configs) && r.configs.includes(config), + ); + return entry ? String(entry.ref ?? "") : null; +} + +function remoteFetched(info, plugin) { + const { git_url: url, ref, config } = plugin.remote; + const names = [ref.replaceAll("-", "--").replaceAll("/", "-"), ref]; + const root = path.join(path.dirname(info.excludeFile), "lefthook-remotes"); + const prefixes = names.map((name) => `${repoName(url)}-${name}`); + // A cache directory with a suffix after the ref counts too; another ref's, or + // another repository's whose name extends this one, does not. + const dirs = existsSync(root) ? readdirSync(root).filter((d) => prefixes.some((p) => d === p || d.startsWith(`${p}-`))) : []; + return dirs.some((d) => readText(path.join(root, d, 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}\\2|^\\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 }] }; + } + // Lefthook looks up a remote's scripts under the consumer's source_dir inside + // the fetched copy, so a source_dir other than the default finds none of them. + const file = lefthookFile(info, "shared"); + const text = readText(path.join(info.top, file)) ?? ""; + const sourceDir = /\.jsonc?$/.test(file) + ? /"source_dir"\s*:\s*"([^"]+)"/.exec(text)?.[1] + : /^source_dir[ \t]*[:=][ \t]*["']?([^"'\s#]+)/m.exec(text)?.[1]; + if (sourceDir && sourceDir.replace(/^\.\//, "").replace(/\/+$/, "") !== ".lefthook") { + return { manager, actions: [], decisions: [{ kind: "remote-source-dir", file, sourceDir }] }; + } + const decisions = []; + const ref = remoteRef(info, plugin); + if (ref === null) decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote) }); + else if (ref !== plugin.remote.ref) { + decisions.push({ kind: "lefthook-config", file, snippet: remoteSnippet(plugin.remote), foundRef: ref }); + } + 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), ...migrateDecision(info, plugin, "shared", manager)); + 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 +680,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 +860,14 @@ export function list(info, plugin) { function entryVerdict(info, plugin, entry, manager) { if (isDeclined(info, plugin, entry)) return { state: "declined" }; + const ref = plugin.remote ? remoteRef(info, plugin) : null; + if (ref !== null) { + if (ref !== plugin.remote.ref) { + return { state: "missing", detail: `lefthook remote ref is "${ref}", not ${plugin.remote.ref}; set ref: ${plugin.remote.ref}` }; + } + 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 +896,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 +917,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 983c9fb9f7e..e6b13fd8509 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -109,7 +109,14 @@ 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` branch (`git-hooks/lefthook-remote.yml`), refetched daily: whoever can merge to that +`main` runs code in every such clone within a day, with its user's privileges and git +credentials. With husky or plain hooks it commits copies under +`.githooks/guardrails/`. A clone whose fetched remote no longer parses (every commit and +`lefthook install` then fail with a YAML error) recovers, once the fix is on `main`, with +`rm -rf "$(git rev-parse --git-path info)"/lefthook-remotes/claude-code-plugins-* && lefthook install`. 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 10633c25187..7dac665c09d 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 00000000000..fe9c5e20a39 --- /dev/null +++ b/plugins/guardrails/git-hooks/lefthook-remote.test.sh @@ -0,0 +1,147 @@ +#!/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; a new commit on main +# reaches the consumer on its next lefthook install. +# 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 .github/scripts/pr-require-checks/install-lefthook.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() { #