diff --git a/distgit/hooks_ruff.py b/distgit/hooks_ruff.py
new file mode 100644
index 0000000..a2be5f4
--- /dev/null
+++ b/distgit/hooks_ruff.py
@@ -0,0 +1,104 @@
+import json
+import subprocess
+import sys
+from pathlib import Path
+from sys import stderr
+from typing import Any
+
+import pygit2
+
+from distgit.hooks import HookResult, PreCommit
+
+"""
+Pre-commit hook: run Ruff lint and format checks with machine-readable JSON on stdout.
+
+Uses ``ruff check --output-format=json`` and
+``ruff format --check --preview --output-format=json`` (preview is required for JSON on ``format``).
+Parses JSON and prints a short summary to stderr on failure.
+"""
+
+
+def _ruff_base() -> list[str]:
+	return [sys.executable, "-m", "ruff"]
+
+
+def _parse_diagnostic_list(raw: str) -> list[dict[str, Any]]:
+	s = raw.strip()
+	if not s:
+		return []
+	data = json.loads(s)
+	if isinstance(data, list):
+		return [x for x in data if isinstance(x, dict)]
+	return []
+
+
+def _format_location(loc: object) -> str:
+	if not isinstance(loc, dict):
+		return "?:?"
+	row = loc.get("row", "?")
+	col = loc.get("column", "?")
+	return f"{row}:{col}"
+
+
+def _summarize_diagnostics(label: str, rows: list[dict[str, Any]]) -> str:
+	lines = [f"{label}:"]
+	for row in rows:
+		fn = row.get("filename", "?")
+		msg = row.get("message", row.get("code", "?"))
+		loc = row.get("location")
+		lines.append(f"  {fn}:{_format_location(loc)}: {msg}")
+	return "\n".join(lines)
+
+
+def _run_ruff_json(
+	args: list[str],
+	cwd: Path,
+) -> tuple[int, list[dict[str, Any]], str]:
+	proc = subprocess.run(
+		_ruff_base() + args,
+		cwd=cwd,
+		capture_output=True,
+		text=True,
+	)
+	stderr_extra = proc.stderr or ""
+	try:
+		rows = _parse_diagnostic_list(proc.stdout)
+	except json.JSONDecodeError:
+		return (
+			1,
+			[],
+			f"invalid JSON from ruff (stdout):\n{proc.stdout!r}\nstderr:\n{stderr_extra}",
+		)
+	return proc.returncode, rows, stderr_extra
+
+
+class PreCommitRuff(PreCommit):
+	def __init__(self, repo: pygit2.Repository, paths: list[str] | None = None):
+		super().__init__(repo)
+		self.paths = paths if paths is not None else ["."]
+
+	def run(self) -> HookResult:
+		workdir = self.repo.workdir
+		if workdir is None:
+			stderr.write("PreCommitRuff: bare repository has no working tree; skipping ruff.\n")
+			return HookResult.SUCCESS
+		root = Path(workdir)
+
+		rc, rows, extra = _run_ruff_json(["check", "--output-format=json", *self.paths], root)
+		if rc != 0:
+			stderr.write(_summarize_diagnostics("ruff check", rows) + "\n")
+			if extra.strip():
+				stderr.write(extra)
+			return HookResult.FAILURE
+
+		rc, rows, extra = _run_ruff_json(
+			["format", "--check", "--preview", "--output-format=json", *self.paths],
+			root,
+		)
+		if rc != 0:
+			stderr.write(_summarize_diagnostics("ruff format", rows) + "\n")
+			if extra.strip():
+				stderr.write(extra)
+			return HookResult.FAILURE
+
+		return HookResult.SUCCESS
