Add staged-file prettier formatting to pre-commit

New scripts/release_control/format_staged_frontend.py mirrors the staged
Go formatter: formats staged frontend-modern/src {ts,tsx,css,json} blobs
through prettier --stdin-filepath, writes results back to the index
directly (no broad restaging), syncs the worktree only when it matches
the previously staged content, and iterates to a fixed point to absorb
prettier's occasional non-idempotence. Skips gracefully when prettier is
not installed (fresh clones, linked worktrees without node_modules).
Wired into .husky/pre-commit after the Go formatter, with unit tests in
the governance battery, a README note, and a .gitignore allowlist entry.
With the one-time sweep in the previous commits, prettier drift can no
longer re-accumulate and make format stays clean on a clean tree.
This commit is contained in:
rcourtman
2026-07-20 10:37:58 +01:00
parent 3a6a6f6825
commit d89e3e3163
5 changed files with 301 additions and 2 deletions
+2
View File
@@ -234,6 +234,8 @@ scripts/release_control/*
!scripts/release_control/control_plane_audit_test.py
!scripts/release_control/dev_runtime_governance_test.py
!scripts/release_control/documentation_currentness_test.py
!scripts/release_control/format_staged_frontend.py
!scripts/release_control/format_staged_frontend_test.py
!scripts/release_control/format_staged_go.py
!scripts/release_control/format_staged_go_test.py
!scripts/release_control/governance_stage_guard.py
+5
View File
@@ -121,6 +121,7 @@ python3 scripts/release_control/canonical_completion_guard_test.py
python3 scripts/release_control/control_plane_audit_test.py
python3 scripts/release_control/contract_audit_test.py
python3 scripts/release_control/format_staged_go_test.py
python3 scripts/release_control/format_staged_frontend_test.py
python3 scripts/release_control/governance_stage_guard_test.py
python3 scripts/release_control/pulse_intelligence_gate_test.py
python3 scripts/release_control/release_promotion_policy_support_test.py
@@ -152,6 +153,10 @@ fi
# Run Go formatting
python3 scripts/release_control/format_staged_go.py
# Run frontend formatting (prettier over staged frontend-modern/src files,
# index-directly like the Go formatter; skips if prettier is not installed)
python3 scripts/release_control/format_staged_frontend.py
# Run Go linting (if golangci-lint is available)
if command -v golangci-lint >/dev/null 2>&1; then
if staged_files_match '(^|/).*\.go$|(^|/)go\.(mod|sum|work|work\.sum)$|^\.golangci\.ya?ml$'; then
+5 -2
View File
@@ -97,8 +97,11 @@ contract update to touch a substantive contract section such as `Purpose`,
`Completion Obligations`, or `Current State`, not just metadata.
Local pre-commit formatting is intentionally scoped to staged files so unrelated
dirty worktree files are not mutated during commit.
The staged Go formatter updates the git index directly and avoids broad
restaging, so partially staged files do not silently absorb unrelated hunks.
The staged Go formatter and the staged frontend prettier formatter both
update the git index directly and avoid broad restaging, so partially staged
files do not silently absorb unrelated hunks. The frontend formatter skips
gracefully when prettier is not installed (fresh clones, linked worktrees
without node_modules).
Local pre-commit also blocks any unstaged edits to hook-sensitive governance
files, so working-tree-only governance changes cannot make local validation
disagree with the committed tree.
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Format staged frontend files with prettier without staging unrelated worktree changes.
Mirrors format_staged_go.py: reads staged blobs, formats them through
prettier's stdin interface, and writes the result back to the git index
directly, so partially staged files do not silently absorb unrelated hunks.
"""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_REPO_ROOT = REPO_ROOT
FRONTEND_DIR = "frontend-modern"
# Keep in sync with frontend-modern's package.json "format" script scope.
STAGED_PATHSPECS = (
f":(glob){FRONTEND_DIR}/src/**/*.ts",
f":(glob){FRONTEND_DIR}/src/**/*.tsx",
f":(glob){FRONTEND_DIR}/src/**/*.css",
f":(glob){FRONTEND_DIR}/src/**/*.json",
)
# Prettier is not always idempotent in a single pass; iterate to a fixed
# point with a small cap so a pathological input cannot loop forever.
MAX_FORMAT_PASSES = 5
def git_env() -> dict[str, str]:
env = os.environ.copy()
# Unit tests patch REPO_ROOT to a temporary repository. In that case, the
# inherited hook environment (alternate index, or the absolute GIT_DIR a
# pre-commit run from a linked worktree exports) should not leak into the
# temp repo and point git plumbing at a different repository.
if REPO_ROOT != DEFAULT_REPO_ROOT:
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
env.pop(name, None)
return env
def git(*args: str, text: bool, input_data: str | bytes | None = None) -> subprocess.CompletedProcess:
return subprocess.run(
["git", *args],
cwd=REPO_ROOT,
env=git_env(),
check=True,
capture_output=True,
text=text,
input=input_data,
)
def prettier_bin() -> Path | None:
override = os.environ.get("PULSE_PRETTIER_BIN")
if override:
candidate = Path(override)
return candidate if candidate.exists() else None
candidate = REPO_ROOT / FRONTEND_DIR / "node_modules" / ".bin" / "prettier"
return candidate if candidate.exists() else None
def staged_frontend_files() -> list[str]:
result = git(
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR",
"-z",
"--",
*STAGED_PATHSPECS,
text=False,
)
return sorted(
entry.decode("utf-8")
for entry in result.stdout.split(b"\x00")
if entry
)
def staged_blob(path: str) -> bytes:
return git("show", f":{path}", text=False).stdout
def staged_mode(path: str) -> str:
output = git("ls-files", "--stage", "--", path, text=True).stdout.strip()
if not output:
raise ValueError(f"missing staged index entry for {path}")
metadata, _, _ = output.partition("\t")
parts = metadata.split()
if len(parts) < 3:
raise ValueError(f"invalid staged index entry for {path}: {output!r}")
return parts[0]
def prettier_bytes(prettier: Path, path: str, data: bytes) -> bytes:
# --stdin-filepath drives parser selection and config resolution, so the
# staged blob is formatted exactly as `prettier --write <path>` would.
current = data
for _ in range(MAX_FORMAT_PASSES):
result = subprocess.run(
[str(prettier), "--stdin-filepath", str(REPO_ROOT / path)],
cwd=REPO_ROOT / FRONTEND_DIR,
check=True,
capture_output=True,
input=current,
)
if result.stdout == current:
break
current = result.stdout
return current
def write_blob_to_index(path: str, *, mode: str, data: bytes) -> None:
blob = (
git("hash-object", "-w", "--stdin", text=False, input_data=data)
.stdout.decode("utf-8")
.strip()
)
git("update-index", "--cacheinfo", f"{mode},{blob},{path}", text=True)
def sync_worktree_if_clean(path: str, previous_staged: bytes, formatted: bytes) -> bool:
absolute = REPO_ROOT / path
if not absolute.exists():
return False
current = absolute.read_bytes()
if current != previous_staged:
return False
absolute.write_bytes(formatted)
return True
def format_staged_frontend_files() -> int:
paths = staged_frontend_files()
if not paths:
print("Skipping frontend formatter (no staged frontend source files).")
return 0
prettier = prettier_bin()
if prettier is None:
# Fresh clones and linked worktrees may not have node_modules; skip
# gracefully like the golangci-lint availability check does. CI's
# prettier check still catches drift that slips through here.
print("Skipping frontend formatter (prettier not installed under frontend-modern/node_modules).")
return 0
print("Running prettier on staged frontend files...")
formatted_count = 0
synced_count = 0
index_only_count = 0
for path in paths:
before = staged_blob(path)
try:
after = prettier_bytes(prettier, path, before)
except subprocess.CalledProcessError as error:
stderr = error.stderr.decode("utf-8", "replace") if error.stderr else ""
print(f"BLOCKED: prettier failed on staged {path}:\n{stderr}", file=sys.stderr)
return 1
if after == before:
continue
write_blob_to_index(path, mode=staged_mode(path), data=after)
formatted_count += 1
if sync_worktree_if_clean(path, before, after):
synced_count += 1
else:
index_only_count += 1
print(
f"Frontend formatter summary: staged_files={len(paths)} formatted={formatted_count} "
f"worktree_synced={synced_count} index_only={index_only_count}"
)
return 0
def main() -> int:
return format_staged_frontend_files()
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,104 @@
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import format_staged_frontend
from format_staged_frontend import format_staged_frontend_files
from repo_file_io import strip_local_git_env
REAL_PRETTIER = (
Path(format_staged_frontend.DEFAULT_REPO_ROOT)
/ "frontend-modern"
/ "node_modules"
/ ".bin"
/ "prettier"
)
class FormatStagedFrontendTest(unittest.TestCase):
def git(self, repo_root: Path, *args: str) -> subprocess.CompletedProcess:
# Scrub the full hook environment: with only GIT_INDEX_FILE removed, a
# pre-commit run from a linked worktree exports an absolute GIT_DIR and
# "git init" here re-initializes the REAL repository as bare.
env = strip_local_git_env(os.environ.copy())
return subprocess.run(
["git", *args],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
env=env,
)
def seed_repo(self, repo_root: Path, source: str) -> Path:
ts_file = repo_root / "frontend-modern" / "src" / "sample.ts"
ts_file.parent.mkdir(parents=True)
ts_file.write_text(source, encoding="utf-8")
self.git(repo_root, "init")
self.git(repo_root, "add", "frontend-modern/src/sample.ts")
return ts_file
def test_skips_gracefully_when_prettier_is_unavailable(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo_root = Path(tmpdir)
unformatted = "const x = {a:1}\n"
ts_file = self.seed_repo(repo_root, unformatted)
with patch.dict(
"os.environ",
{"PULSE_PRETTIER_BIN": str(repo_root / "missing-prettier")},
clear=False,
):
with patch("format_staged_frontend.REPO_ROOT", repo_root):
exit_code = format_staged_frontend_files()
self.assertEqual(exit_code, 0)
self.assertEqual(ts_file.read_text(encoding="utf-8"), unformatted)
staged = self.git(repo_root, "show", ":frontend-modern/src/sample.ts").stdout
self.assertEqual(staged, unformatted)
@unittest.skipUnless(REAL_PRETTIER.exists(), "prettier not installed under frontend-modern")
def test_formats_staged_frontend_and_syncs_clean_worktree(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo_root = Path(tmpdir)
ts_file = self.seed_repo(repo_root, "const x = {a:1}\n")
with patch.dict(
"os.environ", {"PULSE_PRETTIER_BIN": str(REAL_PRETTIER)}, clear=False
):
with patch("format_staged_frontend.REPO_ROOT", repo_root):
exit_code = format_staged_frontend_files()
self.assertEqual(exit_code, 0)
self.assertEqual(ts_file.read_text(encoding="utf-8"), "const x = { a: 1 };\n")
staged = self.git(repo_root, "show", ":frontend-modern/src/sample.ts").stdout
self.assertEqual(staged, ts_file.read_text(encoding="utf-8"))
@unittest.skipUnless(REAL_PRETTIER.exists(), "prettier not installed under frontend-modern")
def test_formats_staged_frontend_without_overwriting_unstaged_worktree_changes(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo_root = Path(tmpdir)
ts_file = self.seed_repo(repo_root, "const x = {a:1}\n")
ts_file.write_text("const x = {a:1}\n// unstaged worktree edit\n", encoding="utf-8")
with patch.dict(
"os.environ", {"PULSE_PRETTIER_BIN": str(REAL_PRETTIER)}, clear=False
):
with patch("format_staged_frontend.REPO_ROOT", repo_root):
exit_code = format_staged_frontend_files()
self.assertEqual(exit_code, 0)
self.assertEqual(
ts_file.read_text(encoding="utf-8"),
"const x = {a:1}\n// unstaged worktree edit\n",
)
staged = self.git(repo_root, "show", ":frontend-modern/src/sample.ts").stdout
self.assertEqual(staged, "const x = { a: 1 };\n")
if __name__ == "__main__":
unittest.main()