From 86262563c1d3f01bca8c78c884f716549adcca38 Mon Sep 17 00:00:00 2001 From: Matt Norris Date: Fri, 25 Sep 2026 14:31:25 -0400 Subject: [PATCH 1/2] feat(essentials-sync): add jargon git hooks A commit-msg hook checks each message against the jargon wordlist, and a pre-push hook checks every unpublished commit's message and added lines, which also catches commits made without hooks. Exceptions live in .essentials-sync-jargon.json at the repo root: file globs whose added lines are never flagged, and literal text blanked before matching. --- .essentials-sync-jargon.json | 9 + .pre-commit-config.yaml | 23 ++ tools/typescript/essentials-sync/README.md | 38 +++ tools/typescript/essentials-sync/src/cli.ts | 13 +- .../essentials-sync/src/jargon-check.ts | 231 ++++++++++++++++++ .../essentials-sync/src/jargon-list.ts | 87 +++++++ .../tests/jargon-check.test.ts | 160 ++++++++++++ .../typescript/essentials-sync/tsconfig.json | 3 +- 8 files changed, 552 insertions(+), 12 deletions(-) create mode 100644 .essentials-sync-jargon.json create mode 100644 tools/typescript/essentials-sync/src/jargon-check.ts create mode 100644 tools/typescript/essentials-sync/tests/jargon-check.test.ts diff --git a/.essentials-sync-jargon.json b/.essentials-sync-jargon.json new file mode 100644 index 0000000..d2c3afa --- /dev/null +++ b/.essentials-sync-jargon.json @@ -0,0 +1,9 @@ +{ + "allow": { + "paths": [ + "tools/typescript/essentials-sync/src/jargon-list.ts", + "tools/typescript/essentials-sync/tests/fixtures/dirty-package/**", + "tools/typescript/essentials-sync/tests/scanners.test.ts" + ] + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59a886d..d8c4258 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,6 +45,26 @@ repos: always_run: true stages: [pre-push] + # Forbidden jargon in commit messages and added lines. The wordlist lives in + # essentials-sync; exceptions live in .essentials-sync-jargon.json. The + # pre-push hook rescans every unpublished commit, so it also catches commits + # made without hooks (git commit-tree, --no-verify). + - repo: local + hooks: + - id: jargon-commit-msg + name: jargon (commit message) + entry: node --disable-warning=ExperimentalWarning tools/typescript/essentials-sync/src/jargon-check.ts --message-file + language: system + always_run: true + stages: [commit-msg] + - id: jargon-push + name: jargon (unpublished commits) + entry: node --disable-warning=ExperimentalWarning tools/typescript/essentials-sync/src/jargon-check.ts + language: system + pass_filenames: false + always_run: true + stages: [pre-push] + # Trailing whitespace and end of file fixes - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 @@ -61,6 +81,9 @@ repos: - id: debug-statements # Global hooks configuration +default_install_hook_types: [pre-commit, pre-push, commit-msg] +# Hooks without an explicit `stages` run on commit and push, not on commit-msg. +default_stages: [pre-commit, pre-push] default_language_version: python: python3.12 diff --git a/tools/typescript/essentials-sync/README.md b/tools/typescript/essentials-sync/README.md index 34cbfc9..9c37fe2 100644 --- a/tools/typescript/essentials-sync/README.md +++ b/tools/typescript/essentials-sync/README.md @@ -100,6 +100,44 @@ Drop a `.essentials-sync-jargon.json` file at the root of the original source to Entries that start with `*.` are treated as hostname suffixes; everything else as case-insensitive word-boundary matches. Per-org employee-ID patterns belong here too -- the bundled PII scanner only flags emails, phone numbers, and SSNs. +## Git hooks + +The same wordlist guards commits to the repo that hosts this tool. `src/jargon-check.ts` runs as two [pre-commit](https://pre-commit.com) hooks: + +| Hook | Stage | Checks | +| --- | --- | --- | +| `jargon-commit-msg` | `commit-msg` | The message being committed, ignoring git comment lines. | +| `jargon-push` | `pre-push` | Every commit not yet on a remote: its message and the lines it adds. | + +The pre-push hook is the backstop: it also catches commits that skipped the commit-msg hook (`git commit --no-verify`, `git commit-tree`). It only scans what a push would publish, so existing history never blocks a push. + +Install both after cloning: + +```bash +uv run pre-commit install +``` + +The hooks run under plain `node` (22.18 or later strips the TypeScript types), so they need no `npm install`. Run the check by hand with: + +```bash +node tools/typescript/essentials-sync/src/jargon-check.ts # unpublished commits +node tools/typescript/essentials-sync/src/jargon-check.ts --to-ref # a specific commit and its unpublished ancestors +``` + +Exceptions live in `.essentials-sync-jargon.json` at the repo root. `allow.paths` are repo-relative globs (`*`, `**`, `?`) whose added lines are never flagged; `allow.text` entries are literal strings removed from a line before matching, so a forbidden term elsewhere on the same line is still caught: + +```json +{ + "terms": ["internal-codename"], + "allow": { + "paths": ["tools/typescript/essentials-sync/tests/**"], + "text": ["docs.example.com"] + } +} +``` + +The exception list applies to the hooks only; `essentials-sync` runs still scan synced packages against the full wordlist. + ## Usage ``` diff --git a/tools/typescript/essentials-sync/src/cli.ts b/tools/typescript/essentials-sync/src/cli.ts index c035557..dc1df1e 100644 --- a/tools/typescript/essentials-sync/src/cli.ts +++ b/tools/typescript/essentials-sync/src/cli.ts @@ -8,6 +8,7 @@ import { runScanners, formatFindings } from "./scanners/index.js"; import { planSync, composeFullPlan } from "./sync.js"; import { planExtract } from "./extract-plan.js"; import { runSyncSession } from "./agent.js"; +import { parseJargonOverrides } from "./jargon-list.js"; import { listAvailableModels, parseModelSpec, @@ -297,17 +298,7 @@ async function loadJargonOverrides(sourceAbs: string): Promise { const configPath = path.join(sourceAbs, ".essentials-sync-jargon.json"); try { const raw = await fs.readFile(configPath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (Array.isArray(parsed)) { - return parsed.filter((entry): entry is string => typeof entry === "string"); - } - if (parsed && typeof parsed === "object") { - const terms = (parsed as { terms?: unknown }).terms; - if (Array.isArray(terms)) { - return terms.filter((entry): entry is string => typeof entry === "string"); - } - } - return []; + return parseJargonOverrides(JSON.parse(raw) as unknown).terms; } catch (error) { if (error instanceof Error && "code" in error && (error as { code?: string }).code === "ENOENT") { return []; diff --git a/tools/typescript/essentials-sync/src/jargon-check.ts b/tools/typescript/essentials-sync/src/jargon-check.ts new file mode 100644 index 0000000..602fc88 --- /dev/null +++ b/tools/typescript/essentials-sync/src/jargon-check.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env node +// Git hook entry point: fails when a commit message, or a line a commit adds, +// contains a forbidden jargon term. It runs under plain `node` (type stripping) +// so the hook works without `npm install`: import only node builtins and +// `./jargon-list.ts` here. +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { + findJargonTerms, + isAllowedPath, + loadJargonConfig, + parseJargonOverrides, + type JargonAllowList, + type JargonConfig, + type JargonOverrides, +} from "./jargon-list.ts"; + +export const CONFIG_FILENAME = ".essentials-sync-jargon.json"; + +const SCISSORS_LINE = /^# -+ >8 -+$/; +const MAX_EXCERPT_LENGTH = 120; + +export interface JargonHit { + location: string; + term: string; + excerpt: string; +} + +export interface AddedLine { + file: string; + line: number; + text: string; +} + +export interface CommitChange { + sha: string; + message: string; + addedLines: AddedLine[]; +} + +// Git hands commit-msg hooks the raw editor buffer: comment lines and, with +// `commit -v`, a diff below the scissors line. Neither is part of the message. +export function readMessageLines(rawMessage: string): string[] { + const lines: string[] = []; + for (const line of rawMessage.split(/\r?\n/)) { + if (SCISSORS_LINE.test(line)) break; + if (!line.startsWith("#")) lines.push(line); + } + return lines; +} + +// Parses `git diff --unified=0` output into the lines it adds. File headers are +// only recognized between `diff --git` and the first hunk, so an added line +// that itself starts with "++ " is not mistaken for a header. +export function parseAddedLines(diff: string): AddedLine[] { + const added: AddedLine[] = []; + let file: string | null = null; + let inHeader = false; + let lineNumber = 0; + for (const raw of diff.split("\n")) { + if (raw.startsWith("diff --git ")) { + inHeader = true; + file = null; + continue; + } + if (inHeader) { + if (raw.startsWith("+++ ")) { + const target = raw.slice(4); + file = target === "/dev/null" ? null : target.replace(/^b\//, ""); + } + if (!raw.startsWith("@@")) continue; + inHeader = false; + } + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); + if (hunk) { + lineNumber = Number(hunk[1]); + continue; + } + if (file !== null && raw.startsWith("+")) { + added.push({ file, line: lineNumber, text: raw.slice(1) }); + lineNumber += 1; + } + } + return added; +} + +const RECORD_SEPARATOR = "\x1e"; +const MESSAGE_END = "\x1f"; + +// Parses `git log -p` output produced with GIT_LOG_FORMAT into one entry per +// commit. +export function parseLog(output: string): CommitChange[] { + const commits: CommitChange[] = []; + for (const record of output.split(RECORD_SEPARATOR)) { + const end = record.indexOf(MESSAGE_END); + if (end === -1) continue; + const [sha = "", ...messageLines] = record.slice(0, end).split("\n"); + commits.push({ + sha, + message: messageLines.join("\n"), + addedLines: parseAddedLines(record.slice(end + 1)), + }); + } + return commits; +} + +const GIT_LOG_FORMAT = `--format=${RECORD_SEPARATOR}%H%n%B${MESSAGE_END}`; + +const excerpt = (text: string): string => { + const trimmed = text.trim(); + return trimmed.length > MAX_EXCERPT_LENGTH + ? `${trimmed.slice(0, MAX_EXCERPT_LENGTH)}...` + : trimmed; +}; + +export function scanMessage( + lines: readonly string[], + locationPrefix: string, + config: JargonConfig, + allow: JargonAllowList, +): JargonHit[] { + const hits: JargonHit[] = []; + lines.forEach((line, index) => { + for (const pattern of findJargonTerms(line, config, allow.text)) { + hits.push({ + location: `${locationPrefix} line ${index + 1}`, + term: pattern.term, + excerpt: excerpt(line), + }); + } + }); + return hits; +} + +export function scanCommits( + commits: readonly CommitChange[], + config: JargonConfig, + allow: JargonAllowList, +): JargonHit[] { + const hits: JargonHit[] = []; + for (const commit of commits) { + const shortSha = commit.sha.slice(0, 7); + hits.push( + ...scanMessage(commit.message.split("\n"), `commit ${shortSha} message`, config, allow), + ); + for (const added of commit.addedLines) { + if (isAllowedPath(added.file, allow.paths)) continue; + for (const pattern of findJargonTerms(added.text, config, allow.text)) { + hits.push({ + location: `commit ${shortSha} ${added.file}:${added.line}`, + term: pattern.term, + excerpt: excerpt(added.text), + }); + } + } + } + return hits; +} + +const git = (cwd: string, args: string[]): string => + execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); + +export function loadRepoOverrides(repoRoot: string): JargonOverrides { + const configPath = path.join(repoRoot, CONFIG_FILENAME); + if (!existsSync(configPath)) { + return parseJargonOverrides(null); + } + return parseJargonOverrides(JSON.parse(readFileSync(configPath, "utf8")) as unknown); +} + +// Commits the push would publish: everything reachable from `toRef` that no +// remote-tracking ref already has. That covers new branches, fast-forwards, +// and rebases alike, and never rescans history that is already public. +export function listUnpublishedCommits(repoRoot: string, toRef: string): CommitChange[] { + const output = git(repoRoot, [ + "-c", "core.quotePath=false", + "log", "-p", "--unified=0", "--no-color", "--no-ext-diff", GIT_LOG_FORMAT, + toRef, "--not", "--remotes", + ]); + return parseLog(output); +} + +function formatHits(hits: readonly JargonHit[]): string { + const lines = hits.map((hit) => ` ${hit.location}: '${hit.term}' in: ${hit.excerpt}`); + return [ + `Forbidden jargon found. Reword it, or add an exception to ${CONFIG_FILENAME}:`, + ...lines, + ].join("\n"); +} + +export function main(argv: readonly string[]): number { + const { values } = parseArgs({ + args: [...argv], + options: { + "message-file": { type: "string" }, + "to-ref": { type: "string" }, + }, + }); + const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]).trim(); + const overrides = loadRepoOverrides(repoRoot); + const config = loadJargonConfig(overrides.terms); + + const messageFile = values["message-file"]; + const hits = messageFile + ? scanMessage( + readMessageLines(readFileSync(messageFile, "utf8")), + "commit message", + config, + overrides.allow, + ) + : scanCommits( + listUnpublishedCommits( + repoRoot, + values["to-ref"] ?? process.env.PRE_COMMIT_TO_REF ?? "HEAD", + ), + config, + overrides.allow, + ); + + if (hits.length === 0) return 0; + console.error(formatHits(hits)); + return 1; +} + +const invokedPath = process.argv[1]; +if (invokedPath && import.meta.url === pathToFileURL(path.resolve(invokedPath)).href) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/tools/typescript/essentials-sync/src/jargon-list.ts b/tools/typescript/essentials-sync/src/jargon-list.ts index 0fc6e09..8d07f6b 100644 --- a/tools/typescript/essentials-sync/src/jargon-list.ts +++ b/tools/typescript/essentials-sync/src/jargon-list.ts @@ -47,6 +47,93 @@ export interface JargonConfig { patterns: JargonPattern[]; } +// Exceptions to the wordlist. `paths` are repo-relative globs (`*`, `**`, `?`) +// whose contents are never flagged; `text` entries are literal strings that are +// blanked out of a line before matching, so an allowed hostname on the same +// line as a forbidden one still leaves the forbidden one flagged. +export interface JargonAllowList { + paths: string[]; + text: string[]; +} + +// Parsed `.essentials-sync-jargon.json`. Accepts a flat array of terms or +// `{ "terms": [...], "allow": { "paths": [...], "text": [...] } }`. +export interface JargonOverrides { + terms: string[]; + allow: JargonAllowList; +} + +const toStrings = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; + +export function parseJargonOverrides(parsed: unknown): JargonOverrides { + if (Array.isArray(parsed)) { + return { terms: toStrings(parsed), allow: { paths: [], text: [] } }; + } + if (!parsed || typeof parsed !== "object") { + return { terms: [], allow: { paths: [], text: [] } }; + } + const { terms, allow } = parsed as { terms?: unknown; allow?: unknown }; + const allowObject = (allow && typeof allow === "object" ? allow : {}) as { + paths?: unknown; + text?: unknown; + }; + return { + terms: toStrings(terms), + allow: { paths: toStrings(allowObject.paths), text: toStrings(allowObject.text) }, + }; +} + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +function globToRegExp(glob: string): RegExp { + let source = ""; + for (let i = 0; i < glob.length; i += 1) { + const char = glob[i] ?? ""; + if (char === "*" && glob[i + 1] === "*") { + // `**/` matches zero or more whole directories; a trailing `**` matches + // everything below. + const hasSlash = glob[i + 2] === "/"; + source += hasSlash ? "(?:.*/)?" : ".*"; + i += hasSlash ? 2 : 1; + } else if (char === "*") { + source += "[^/]*"; + } else if (char === "?") { + source += "[^/]"; + } else { + source += escapeRegExp(char); + } + } + return new RegExp(`^${source}$`); +} + +export function isAllowedPath(relativePath: string, allowPaths: readonly string[]): boolean { + const normalized = relativePath.split("\\").join("/"); + return allowPaths.some((glob) => globToRegExp(glob).test(normalized)); +} + +export function maskAllowedText(line: string, allowText: readonly string[]): string { + let masked = line; + for (const text of allowText) { + if (text) { + masked = masked.replace(new RegExp(escapeRegExp(text), "gi"), " "); + } + } + return masked; +} + +export function findJargonTerms( + line: string, + config: JargonConfig, + allowText: readonly string[] = [], +): JargonPattern[] { + const masked = maskAllowedText(line, allowText); + return config.patterns.filter((pattern) => pattern.regex.test(masked)); +} + export function loadJargonConfig(extraTerms: string[] = []): JargonConfig { const extras: JargonPattern[] = extraTerms.map((raw) => { const trimmed = raw.trim(); diff --git a/tools/typescript/essentials-sync/tests/jargon-check.test.ts b/tools/typescript/essentials-sync/tests/jargon-check.test.ts new file mode 100644 index 0000000..e4d1a0c --- /dev/null +++ b/tools/typescript/essentials-sync/tests/jargon-check.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listUnpublishedCommits, + parseAddedLines, + readMessageLines, + scanCommits, + scanMessage, +} from "../src/jargon-check.js"; +import { + findJargonTerms, + isAllowedPath, + loadJargonConfig, + parseJargonOverrides, +} from "../src/jargon-list.js"; + +// Made-up terms, so this file needs no exception in the repo's allow list. +const config = loadJargonConfig(["codename", "*.corp.example"]); +const noAllow = { paths: [], text: [] }; + +describe("parseJargonOverrides", () => { + it("accepts a flat array of terms", () => { + expect(parseJargonOverrides(["codename"])).toEqual({ + terms: ["codename"], + allow: { paths: [], text: [] }, + }); + }); + + it("reads terms and both allow lists, dropping non-strings", () => { + const overrides = parseJargonOverrides({ + terms: ["codename", 7], + allow: { paths: ["docs/**"], text: ["docs.corp.example", null] }, + }); + expect(overrides).toEqual({ + terms: ["codename"], + allow: { paths: ["docs/**"], text: ["docs.corp.example"] }, + }); + }); + + it("treats a missing or malformed file as empty", () => { + expect(parseJargonOverrides(null)).toEqual({ terms: [], allow: { paths: [], text: [] } }); + expect(parseJargonOverrides({ allow: "docs/**" }).allow).toEqual({ paths: [], text: [] }); + }); +}); + +describe("isAllowedPath", () => { + it("matches exact paths and ** at any depth", () => { + expect(isAllowedPath("src/jargon-list.ts", ["src/jargon-list.ts"])).toBe(true); + expect(isAllowedPath("tests/fixtures/dirty/src/client.py", ["tests/**"])).toBe(true); + expect(isAllowedPath("pkg/a/b/notes.md", ["**/*.md"])).toBe(true); + }); + + it("keeps a single * within one directory", () => { + expect(isAllowedPath("docs/nested/readme.md", ["docs/*.md"])).toBe(false); + expect(isAllowedPath("src/jargon-list.ts.bak", ["src/jargon-list.ts"])).toBe(false); + }); +}); + +describe("findJargonTerms", () => { + it("blanks allowed text but still flags a forbidden term on the same line", () => { + const line = "see https://docs.corp.example and https://auth.corp.example"; + const terms = findJargonTerms(line, config, ["docs.corp.example"]).map((p) => p.term); + expect(terms).toEqual(["*.corp.example"]); + expect(findJargonTerms("see https://docs.corp.example", config, ["docs.corp.example"])).toEqual([]); + }); + + it("matches whole words only, so a longer name that starts with a term passes", () => { + expect(findJargonTerms("github.com/CodenameDevNet/essentials", config)).toEqual([]); + expect(findJargonTerms("the codename-auth tool", config).map((p) => p.term)).toEqual(["codename"]); + }); +}); + +describe("readMessageLines", () => { + it("drops comment lines and everything below the scissors line", () => { + const raw = [ + "feat: add a thing", + "# Please enter the commit message", + "", + "Body text.", + "# ------------------------ >8 ------------------------", + "+ added codename line in the verbose diff", + ].join("\n"); + expect(readMessageLines(raw)).toEqual(["feat: add a thing", "", "Body text."]); + }); +}); + +describe("scanMessage", () => { + it("reports the term and line of a forbidden word", () => { + const hits = scanMessage(["fix: tidy", "", "Matches the codename setup."], "msg", config, noAllow); + expect(hits).toEqual([ + { location: "msg line 3", term: "codename", excerpt: "Matches the codename setup." }, + ]); + }); +}); + +describe("parseAddedLines", () => { + it("returns added lines with their new-file line numbers", () => { + const diff = [ + "diff --git a/app.py b/app.py", + "index 1111111..2222222 100644", + "--- a/app.py", + "+++ b/app.py", + "@@ -3,0 +4,2 @@ def main():", + "+first = 1", + "+++ second", + "diff --git a/gone.py b/gone.py", + "deleted file mode 100644", + "--- a/gone.py", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-removed", + ].join("\n"); + expect(parseAddedLines(diff)).toEqual([ + { file: "app.py", line: 4, text: "first = 1" }, + { file: "app.py", line: 5, text: "++ second" }, + ]); + }); +}); + +describe("scanCommits against a real repository", () => { + let repo = ""; + + afterEach(() => { + if (repo) rmSync(repo, { recursive: true, force: true }); + }); + + const git = (...args: string[]) => + execFileSync("git", args, { cwd: repo, encoding: "utf8" }); + + const commit = (message: string, files: Record) => { + for (const [file, content] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(repo, file)), { recursive: true }); + writeFileSync(path.join(repo, file), content); + } + git("add", "-A"); + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-q", "--no-verify", "-m", message); + }; + + it("flags messages and added lines, honoring allowed paths and text", () => { + repo = mkdtempSync(path.join(tmpdir(), "jargon-check-")); + git("init", "-q"); + commit("chore: start", { "README.md": "hello\n" }); + commit("chore: align with codename", { + "src/client.py": 'HOST = "auth.corp.example"\nDOCS = "https://docs.corp.example"\n', + "tests/fixtures/dirty.py": 'HOST = "auth.corp.example"\n', + }); + + const allow = { paths: ["tests/**"], text: ["docs.corp.example"] }; + const hits = scanCommits(listUnpublishedCommits(repo, "HEAD"), config, allow); + const summary = hits.map((hit) => `${hit.location.replace(/[0-9a-f]{7}/, "SHA")} ${hit.term}`); + + expect(summary).toEqual([ + "commit SHA message line 1 codename", + "commit SHA src/client.py:1 *.corp.example", + ]); + }); +}); diff --git a/tools/typescript/essentials-sync/tsconfig.json b/tools/typescript/essentials-sync/tsconfig.json index a35ec3e..e981e7d 100644 --- a/tools/typescript/essentials-sync/tsconfig.json +++ b/tools/typescript/essentials-sync/tsconfig.json @@ -14,7 +14,8 @@ "resolveJsonModule": true, "declaration": true, "sourceMap": true, - "skipLibCheck": true + "skipLibCheck": true, + "rewriteRelativeImportExtensions": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] From 53edb975b364af08eaaddcfdba4bcaceed0c5c39 Mon Sep 17 00:00:00 2001 From: Matt Norris Date: Fri, 25 Sep 2026 15:00:42 -0400 Subject: [PATCH 2/2] fix(pre-commit): let commit-msg hooks see message The global exclude listed \.git/, which matched .git/COMMIT_EDITMSG, so the jargon commit-msg hook received no file and failed on every commit. --- .pre-commit-config.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d8c4258..9ad9e9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -88,6 +88,9 @@ default_language_version: python: python3.12 # Exclude paths +# +# `\.git/` is deliberately absent: nothing under it is ever staged, but it did +# match `.git/COMMIT_EDITMSG`, the file every commit-msg hook is handed. exclude: | (?x)^( archive/| @@ -95,7 +98,6 @@ exclude: | venv/| node_modules/| __pycache__/| - \.git/| \.pytest_cache/| \.pulumi/| dist/|