mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-17 00:35:22 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user