mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:39:09 +03:00
contrib header changes
This commit is contained in:
86
scripts/apply-project-headers.py
Executable file
86
scripts/apply-project-headers.py
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply HEADER.txt blocks to all project sources."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from header_lib import (
|
||||
REPO_ROOT,
|
||||
apply_header_to_content,
|
||||
has_valid_header,
|
||||
header_needs_identity_refresh,
|
||||
is_excluded_path,
|
||||
iter_source_files,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Apply project headers to source files")
|
||||
parser.add_argument("--check", action="store_true", help="Only report files missing headers")
|
||||
parser.add_argument(
|
||||
"--fix-identity",
|
||||
action="store_true",
|
||||
help="Re-apply headers where contributors lack email but git has one",
|
||||
)
|
||||
parser.add_argument("--path", type=str, default="", help="Limit to one file or directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = REPO_ROOT
|
||||
targets = list(iter_source_files(root))
|
||||
if args.path:
|
||||
p = Path(args.path)
|
||||
if not p.is_absolute():
|
||||
p = REPO_ROOT / p
|
||||
if p.is_file():
|
||||
targets = [p]
|
||||
else:
|
||||
targets = [f for f in iter_source_files(root) if str(f).startswith(str(p))]
|
||||
|
||||
missing = []
|
||||
identity_fix = []
|
||||
changed = 0
|
||||
skipped_vendor = 0
|
||||
for path in sorted(targets):
|
||||
if is_excluded_path(path):
|
||||
skipped_vendor += 1
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if args.check:
|
||||
if not has_valid_header(text):
|
||||
missing.append(path.relative_to(REPO_ROOT))
|
||||
elif header_needs_identity_refresh(text, path):
|
||||
identity_fix.append(path.relative_to(REPO_ROOT))
|
||||
continue
|
||||
if args.fix_identity:
|
||||
if not header_needs_identity_refresh(text, path) and has_valid_header(text):
|
||||
continue
|
||||
new_text = apply_header_to_content(path, text)
|
||||
if new_text != text:
|
||||
path.write_text(new_text, encoding="utf-8", newline="\n")
|
||||
changed += 1
|
||||
|
||||
if args.check:
|
||||
if missing:
|
||||
print("Missing headers:", len(missing))
|
||||
for m in missing[:50]:
|
||||
print(" ", m)
|
||||
if len(missing) > 50:
|
||||
print(" ...")
|
||||
if identity_fix:
|
||||
print("Identity refresh suggested:", len(identity_fix))
|
||||
for m in identity_fix[:30]:
|
||||
print(" ", m)
|
||||
if not missing and not identity_fix:
|
||||
print("All", len(targets) - skipped_vendor, "tracked files OK (third-party skipped).")
|
||||
return 0
|
||||
return 1 if missing else 0
|
||||
|
||||
note = f" (skipped {skipped_vendor} under third-party/vendor paths)" if skipped_vendor else ""
|
||||
print(f"Updated {changed} / {len(targets) - skipped_vendor} source files{note}.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -8,7 +8,8 @@
|
||||
# v0/ota/channel/stable.json
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_manifest.json
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_sign.json
|
||||
# (copy APK to …/android_cast_00.MM.mm.BB.otapkg if out-dir set)
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB.otapkg
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB.otabundle.zip (STORE zip)
|
||||
set -euo pipefail
|
||||
|
||||
apk="${1:?APK path required}"
|
||||
@@ -48,7 +49,9 @@ artifact_base="${base}/android_cast_${ver_p}.${bld_p}"
|
||||
apk_url="${artifact_base}.otapkg"
|
||||
sign_url="${artifact_base}_sign.json"
|
||||
manifest_url="${artifact_base}_manifest.json"
|
||||
bundle_url="${artifact_base}.otabundle.zip"
|
||||
sha256="$(sha256sum "$apk" | awk '{print $1}')"
|
||||
apk_size="$(stat -c%s "$apk" 2>/dev/null || wc -c <"$apk" | tr -d ' ')"
|
||||
|
||||
rel_root="${out_dir}/v0/ota"
|
||||
rel_dir="${rel_root}/00/${maj_p}/${ver_p}"
|
||||
@@ -57,14 +60,18 @@ mkdir -p "${rel_dir}" "${rel_root}/channel"
|
||||
pkg_name="android_cast_${ver_p}.${bld_p}.otapkg"
|
||||
cp -f "$apk" "${rel_dir}/${pkg_name}"
|
||||
|
||||
cat >"${rel_dir}/android_cast_${ver_p}.${bld_p}_sign.json" <<EOF
|
||||
sign_name="android_cast_${ver_p}.${bld_p}_sign.json"
|
||||
manifest_name="android_cast_${ver_p}.${bld_p}_manifest.json"
|
||||
bundle_name="android_cast_${ver_p}.${bld_p}.otabundle.zip"
|
||||
|
||||
cat >"${rel_dir}/${sign_name}" <<EOF
|
||||
{
|
||||
"schema": "v0",
|
||||
"sha256": "${sha256}"
|
||||
}
|
||||
EOF
|
||||
|
||||
cat >"${rel_dir}/android_cast_${ver_p}.${bld_p}_manifest.json" <<EOF
|
||||
cat >"${rel_dir}/${manifest_name}" <<EOF
|
||||
{
|
||||
"schema": "v0",
|
||||
"major": ${major},
|
||||
@@ -73,6 +80,36 @@ cat >"${rel_dir}/android_cast_${ver_p}.${bld_p}_manifest.json" <<EOF
|
||||
"versionName": "${version_name:-${major}.${minor}.${build}}",
|
||||
"apkUrl": "${apk_url}",
|
||||
"signUrl": "${sign_url}",
|
||||
"sizeBytes": ${apk_size},
|
||||
"mandatory": false,
|
||||
"releaseNotes": ""
|
||||
}
|
||||
EOF
|
||||
|
||||
bundle_stage="$(mktemp -d)"
|
||||
trap 'rm -rf "${bundle_stage}"' EXIT
|
||||
cp -f "${rel_dir}/${manifest_name}" "${bundle_stage}/manifest.json"
|
||||
cp -f "${rel_dir}/${sign_name}" "${bundle_stage}/sign.json"
|
||||
cp -f "$apk" "${bundle_stage}/package.apk"
|
||||
# -0 = STORED (no re-compression of the APK)
|
||||
( cd "${bundle_stage}" && zip -0 -q -X "${rel_dir}/${bundle_name}" manifest.json sign.json package.apk )
|
||||
bundle_sha256="$(sha256sum "${rel_dir}/${bundle_name}" | awk '{print $1}')"
|
||||
bundle_size="$(stat -c%s "${rel_dir}/${bundle_name}" 2>/dev/null || wc -c <"${rel_dir}/${bundle_name}" | tr -d ' ')"
|
||||
|
||||
# Patch manifest with bundle fields (jq optional; use temp file + heredoc rewrite)
|
||||
cat >"${rel_dir}/${manifest_name}" <<EOF
|
||||
{
|
||||
"schema": "v0",
|
||||
"major": ${major},
|
||||
"minor": ${minor},
|
||||
"build": ${build},
|
||||
"versionName": "${version_name:-${major}.${minor}.${build}}",
|
||||
"apkUrl": "${apk_url}",
|
||||
"signUrl": "${sign_url}",
|
||||
"sizeBytes": ${apk_size},
|
||||
"bundleUrl": "${bundle_url}",
|
||||
"bundleSha256": "${bundle_sha256}",
|
||||
"bundleSizeBytes": ${bundle_size},
|
||||
"mandatory": false,
|
||||
"releaseNotes": ""
|
||||
}
|
||||
@@ -87,4 +124,5 @@ EOF
|
||||
|
||||
echo "Published v0 OTA under ${out_dir}/v0/ota" >&2
|
||||
echo "Channel: ${host_base%/}/v0/ota/channel/stable.json" >&2
|
||||
echo "Bundle: ${bundle_url} (${bundle_size} bytes)" >&2
|
||||
cat "${rel_root}/channel/stable.json"
|
||||
|
||||
86
scripts/header-pre-commit.py
Executable file
86
scripts/header-pre-commit.py
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git pre-commit: validate headers on new files; refresh Updated/Digest on staged edits."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from header_lib import (
|
||||
REPO_ROOT,
|
||||
SOURCE_SUFFIXES,
|
||||
apply_header_to_content,
|
||||
has_valid_header,
|
||||
header_needs_identity_refresh,
|
||||
is_excluded_path,
|
||||
resolve_header_contributor,
|
||||
update_header_timestamps,
|
||||
)
|
||||
|
||||
|
||||
def staged_files() -> list[Path]:
|
||||
out = subprocess.check_output(
|
||||
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
)
|
||||
files = []
|
||||
for line in out.splitlines():
|
||||
p = REPO_ROOT / line.strip()
|
||||
if not p.is_file() or p.suffix.lower() not in SOURCE_SUFFIXES:
|
||||
continue
|
||||
if is_excluded_path(p):
|
||||
continue
|
||||
files.append(p)
|
||||
return files
|
||||
|
||||
|
||||
def is_new_file(path: Path) -> bool:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
try:
|
||||
subprocess.check_call(
|
||||
["git", "diff", "--cached", "--diff-filter=A", "--quiet", "--", rel],
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
return False
|
||||
except subprocess.CalledProcessError:
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors = []
|
||||
contributor = resolve_header_contributor()
|
||||
for path in staged_files():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if is_new_file(path):
|
||||
if not has_valid_header(text):
|
||||
errors.append(
|
||||
f"{path.relative_to(REPO_ROOT)}: new file must include HEADER.txt block"
|
||||
)
|
||||
text = apply_header_to_content(path, text)
|
||||
text = update_header_timestamps(text, path, contributor)
|
||||
else:
|
||||
if has_valid_header(text):
|
||||
if header_needs_identity_refresh(text, path):
|
||||
text = apply_header_to_content(path, text)
|
||||
text = update_header_timestamps(text, path, contributor)
|
||||
else:
|
||||
text = apply_header_to_content(path, text)
|
||||
text = update_header_timestamps(text, path, contributor)
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
subprocess.check_call(["git", "add", "--", rel], cwd=REPO_ROOT)
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(e, file=sys.stderr)
|
||||
print(
|
||||
"Run: python3 scripts/apply-project-headers.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
499
scripts/header_lib.py
Executable file
499
scripts/header_lib.py
Executable file
@@ -0,0 +1,499 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared logic for project source headers (see HEADER.txt)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HEADER_TEMPLATE = REPO_ROOT / "HEADER.txt"
|
||||
BANNER_START = "/*********************************************************************"
|
||||
BANNER_END = "**********************************************************************/"
|
||||
MARKER_CREATED = "Created at:"
|
||||
MARKER_UPDATED = "Updated at:"
|
||||
MARKER_DIGEST = "Digest:"
|
||||
MARKER_CONTRIBUTORS = "Contributors:"
|
||||
MARKER_CLASS = re.compile(r"^\s*\*?\s*(class|interface|enum)\s+(\w+)", re.I)
|
||||
|
||||
ASSISTANT_NAME = "Cursor Agent"
|
||||
ASSISTANT_CONTRIBUTOR = ASSISTANT_NAME
|
||||
IDENTITY_IN_ANGLE = re.compile(r"^(.+?)\s*<([^>]+)>\s*$")
|
||||
IGNORED_GIT_AUTHORS = frozenset({"Not Committed Yet", "Unknown"})
|
||||
SOURCE_SUFFIXES = {".java", ".aidl", ".c", ".cpp", ".h", ".php"}
|
||||
|
||||
SKIP_DIR_NAMES = {
|
||||
".git",
|
||||
".gradle",
|
||||
"build",
|
||||
".cxx",
|
||||
"third-party",
|
||||
"node_modules",
|
||||
".idea",
|
||||
}
|
||||
EXCLUDED_PATH_MARKERS = frozenset({"third-party"})
|
||||
|
||||
|
||||
def is_excluded_path(path: Path) -> bool:
|
||||
"""Never apply headers under third-party/ or other vendor trees."""
|
||||
resolved = path.resolve()
|
||||
root = REPO_ROOT.resolve()
|
||||
try:
|
||||
rel_parts = resolved.relative_to(root).parts
|
||||
except ValueError:
|
||||
return True
|
||||
if any(part in EXCLUDED_PATH_MARKERS for part in rel_parts):
|
||||
return True
|
||||
return any(part in SKIP_DIR_NAMES for part in rel_parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMeta:
|
||||
rel_path: str
|
||||
filename: str
|
||||
package_line: str
|
||||
class_line: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_contributor: str
|
||||
contributors_block: str
|
||||
language: str
|
||||
|
||||
|
||||
def iter_source_files(root: Path) -> Iterable[Path]:
|
||||
for path in root.rglob("*"):
|
||||
if path.suffix.lower() not in SOURCE_SUFFIXES:
|
||||
continue
|
||||
if is_excluded_path(path):
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
_GLOBAL_IDENTITY_MAP: dict[str, str] | None = None
|
||||
|
||||
|
||||
def global_git_identity_map() -> dict[str, str]:
|
||||
"""Repo-wide author -> email from git shortlog."""
|
||||
global _GLOBAL_IDENTITY_MAP
|
||||
if _GLOBAL_IDENTITY_MAP is not None:
|
||||
return _GLOBAL_IDENTITY_MAP
|
||||
emails: dict[str, str] = {}
|
||||
out = run_git(["shortlog", "-sne", "--all"], REPO_ROOT)
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# "29\tAnton Afanasyeu <a@b.c>" or "29 Anton ..."
|
||||
if "\t" in line:
|
||||
_, rest = line.split("\t", 1)
|
||||
else:
|
||||
parts = line.split(None, 1)
|
||||
rest = parts[1] if len(parts) > 1 else line
|
||||
name, mail = parse_identity(rest.strip())
|
||||
if is_tracked_author(name) and mail:
|
||||
emails.setdefault(name, mail)
|
||||
_GLOBAL_IDENTITY_MAP = emails
|
||||
return emails
|
||||
|
||||
|
||||
def run_git(args: list[str], cwd: Path) -> str:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
return out.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_email(raw: str) -> str:
|
||||
e = raw.strip()
|
||||
if e.startswith("<") and e.endswith(">"):
|
||||
e = e[1:-1].strip()
|
||||
if not e or e.lower() in {"unknown", "none", "n/a"}:
|
||||
return ""
|
||||
if "@" not in e:
|
||||
return ""
|
||||
return e
|
||||
|
||||
|
||||
def parse_identity(text: str) -> tuple[str, str]:
|
||||
"""Split 'Name <email>' or plain name."""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return "", ""
|
||||
m = IDENTITY_IN_ANGLE.match(t)
|
||||
if m:
|
||||
return m.group(1).strip(), normalize_email(m.group(2))
|
||||
return t, ""
|
||||
|
||||
|
||||
def is_tracked_author(name: str) -> bool:
|
||||
n = name.strip()
|
||||
return bool(n) and n not in IGNORED_GIT_AUTHORS
|
||||
|
||||
|
||||
def format_identity(name: str, email: str = "") -> str:
|
||||
name = name.strip()
|
||||
email = normalize_email(email)
|
||||
if not name:
|
||||
return ASSISTANT_NAME
|
||||
if email:
|
||||
return f"{name} <{email}>"
|
||||
return name
|
||||
|
||||
|
||||
def format_git_date(raw: str) -> str:
|
||||
if not raw:
|
||||
return datetime.now().astimezone().strftime("%a %d %b %Y %H:%M:%S %z")
|
||||
try:
|
||||
# git %ai: 2026-05-20 15:04:12 +0300
|
||||
dt = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S %z")
|
||||
return dt.strftime("%a %d %b %Y %H:%M:%S %z")
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
def git_identity_map(path: Path, body: str = "") -> dict[str, str]:
|
||||
"""Map git author name -> email (best effort per file)."""
|
||||
rel = repo_relative(path)
|
||||
emails: dict[str, str] = {}
|
||||
log = run_git(["log", "--follow", "--format=%an%x09%ae", "--", rel], REPO_ROOT)
|
||||
for line in log.splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
an, ae = line.split("\t", 1)
|
||||
an, ae = an.strip(), normalize_email(ae)
|
||||
if is_tracked_author(an) and ae:
|
||||
emails.setdefault(an, ae)
|
||||
|
||||
blame = run_git(["blame", "--line-porcelain", "--", rel], REPO_ROOT)
|
||||
author_name: str | None = None
|
||||
for line in blame.splitlines():
|
||||
if line.startswith("author "):
|
||||
author_name = line[7:].strip()
|
||||
elif line.startswith("author-mail "):
|
||||
mail = normalize_email(line[12:])
|
||||
if is_tracked_author(author_name or "") and mail:
|
||||
emails.setdefault(author_name, mail)
|
||||
author_name = None
|
||||
elif line.startswith("\t"):
|
||||
author_name = None
|
||||
|
||||
for tag in re.finditer(
|
||||
r"@author\s+(.+)$|/\*\s*@author\s+(.+?)\s*\*/",
|
||||
body,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
):
|
||||
fragment = (tag.group(1) or tag.group(2) or "").strip()
|
||||
name, mail = parse_identity(fragment)
|
||||
if name and mail:
|
||||
emails.setdefault(name, mail)
|
||||
for name, mail in global_git_identity_map().items():
|
||||
emails.setdefault(name, mail)
|
||||
return emails
|
||||
|
||||
|
||||
def git_created_updated(path: Path, body: str = "") -> tuple[str, str, str]:
|
||||
rel = repo_relative(path)
|
||||
created_raw = run_git(
|
||||
["log", "--follow", "--diff-filter=A", "--format=%ai", "-1", "--", rel],
|
||||
REPO_ROOT,
|
||||
)
|
||||
updated_raw = run_git(["log", "-1", "--format=%ai", "--", rel], REPO_ROOT)
|
||||
last = run_git(["log", "-1", "--format=%an%x09%ae", "--", rel], REPO_ROOT)
|
||||
if "\t" in last:
|
||||
an, ae = last.split("\t", 1)
|
||||
author = format_identity(an, ae)
|
||||
elif last:
|
||||
id_map = git_identity_map(path, body)
|
||||
author = format_identity(last, id_map.get(last, ""))
|
||||
else:
|
||||
author = ASSISTANT_NAME
|
||||
return (
|
||||
format_git_date(created_raw),
|
||||
format_git_date(updated_raw or created_raw),
|
||||
author,
|
||||
)
|
||||
|
||||
|
||||
def git_config_identity() -> tuple[str, str]:
|
||||
name = run_git(["config", "user.name"], REPO_ROOT)
|
||||
email = run_git(["config", "user.email"], REPO_ROOT)
|
||||
return name, email
|
||||
|
||||
|
||||
def git_config_identity_formatted() -> str:
|
||||
name, email = git_config_identity()
|
||||
if name:
|
||||
return format_identity(name, email)
|
||||
return ASSISTANT_NAME
|
||||
|
||||
|
||||
def resolve_header_contributor() -> str:
|
||||
"""
|
||||
Pre-commit / env contributor: HEADER_CONTRIBUTOR or HEADER_CONTRIBUTOR_EMAIL
|
||||
enriched with git user.email when the name has no address.
|
||||
"""
|
||||
import os
|
||||
|
||||
raw = os.environ.get("HEADER_CONTRIBUTOR", "").strip()
|
||||
email_extra = normalize_email(os.environ.get("HEADER_CONTRIBUTOR_EMAIL", ""))
|
||||
cfg_name, cfg_email = git_config_identity()
|
||||
|
||||
if raw:
|
||||
name, email = parse_identity(raw)
|
||||
if not name:
|
||||
name = raw
|
||||
if not email:
|
||||
email = email_extra or cfg_email or global_git_identity_map().get(name, "")
|
||||
return format_identity(name, email)
|
||||
|
||||
if email_extra and cfg_name:
|
||||
return format_identity(cfg_name, email_extra)
|
||||
return git_config_identity_formatted()
|
||||
|
||||
|
||||
def git_contributors(path: Path, body_line_count: int, body: str = "") -> list[tuple[str, int, int]]:
|
||||
rel = repo_relative(path)
|
||||
id_map = git_identity_map(path, body)
|
||||
commit_counts: dict[str, int] = {}
|
||||
log = run_git(["log", "--follow", "--format=%an", "--", rel], REPO_ROOT)
|
||||
for line in log.splitlines():
|
||||
name = line.strip()
|
||||
if is_tracked_author(name):
|
||||
commit_counts[name] = commit_counts.get(name, 0) + 1
|
||||
|
||||
line_counts: dict[str, int] = {}
|
||||
blame = run_git(["blame", "--line-porcelain", "--", rel], REPO_ROOT)
|
||||
author: str | None = None
|
||||
for line in blame.splitlines():
|
||||
if line.startswith("author "):
|
||||
author = line[7:].strip()
|
||||
elif line.startswith("\t") and author and is_tracked_author(author):
|
||||
line_counts[author] = line_counts.get(author, 0) + 1
|
||||
|
||||
names = {n for n in (set(commit_counts) | set(line_counts)) if is_tracked_author(n)}
|
||||
rows: list[tuple[str, int, int]] = []
|
||||
for name in names:
|
||||
label = format_identity(name, id_map.get(name, ""))
|
||||
rows.append((label, commit_counts.get(name, 0), line_counts.get(name, 0)))
|
||||
rows.sort(key=lambda r: (-r[1], -r[2], r[0].lower()))
|
||||
if not rows:
|
||||
rows = [(ASSISTANT_NAME, 0, body_line_count)]
|
||||
return rows
|
||||
|
||||
|
||||
def contributors_block(rows: list[tuple[str, int, int]], include_assistant: bool = True) -> str:
|
||||
lines = []
|
||||
for label, commits, nlines in rows:
|
||||
name, _ = parse_identity(label)
|
||||
if name == ASSISTANT_NAME:
|
||||
continue
|
||||
lines.append(f" * - {label} ({commits} commits, {nlines} lines)")
|
||||
if include_assistant:
|
||||
lines.append(f" * - {ASSISTANT_NAME} (project assistant)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def detect_class_line(body: str, suffix: str) -> str:
|
||||
if suffix not in {".java", ".aidl", ".cpp", ".php"}:
|
||||
return ""
|
||||
patterns = [
|
||||
("class", r"^\s*public\s+(?:final\s+)?class\s+(\w+)"),
|
||||
("class", r"^\s*class\s+(\w+)"),
|
||||
("interface", r"^\s*public\s+interface\s+(\w+)"),
|
||||
("interface", r"^\s*interface\s+(\w+)"),
|
||||
("enum", r"^\s*public\s+enum\s+(\w+)"),
|
||||
("enum", r"^\s*enum\s+(\w+)"),
|
||||
]
|
||||
for line in body.splitlines():
|
||||
for kind, pat in patterns:
|
||||
m = re.match(pat, line)
|
||||
if m:
|
||||
return f" * {kind} {m.group(1)}"
|
||||
return ""
|
||||
|
||||
|
||||
def package_line_for_file(source_text: str, rel_path: str, suffix: str) -> str:
|
||||
for line in source_text.splitlines():
|
||||
if suffix in {".java", ".aidl"}:
|
||||
m = re.match(r"^\s*package\s+([\w.]+)\s*;", line)
|
||||
if m:
|
||||
return f"package {m.group(1)};"
|
||||
return f"/* package {rel_path} */"
|
||||
|
||||
|
||||
def strip_existing_header(text: str, suffix: str) -> tuple[str, str]:
|
||||
"""Returns (body without header/package, preserved leading e.g. <?php)."""
|
||||
leading = ""
|
||||
work = text
|
||||
if suffix == ".php" and work.startswith("<?php"):
|
||||
first_nl = work.find("\n")
|
||||
if first_nl >= 0:
|
||||
leading = work[: first_nl + 1]
|
||||
work = work[first_nl + 1 :]
|
||||
else:
|
||||
leading = work + "\n"
|
||||
work = ""
|
||||
|
||||
lines = work.splitlines(keepends=True)
|
||||
i = 0
|
||||
if i < len(lines) and (
|
||||
lines[i].startswith("package ")
|
||||
or lines[i].startswith("/* package ")
|
||||
):
|
||||
i += 1
|
||||
if i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
|
||||
if i < len(lines) and BANNER_START in lines[i]:
|
||||
while i < len(lines) and BANNER_END not in lines[i]:
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
i += 1
|
||||
if i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
|
||||
body = "".join(lines[i:]).lstrip("\n")
|
||||
if body.startswith("package "):
|
||||
first_nl = body.find("\n")
|
||||
if first_nl >= 0:
|
||||
body = body[first_nl + 1 :].lstrip("\n")
|
||||
return body, leading
|
||||
|
||||
|
||||
def repo_relative(path: Path) -> str:
|
||||
root = REPO_ROOT.resolve()
|
||||
try:
|
||||
return path.resolve().relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return path.name
|
||||
|
||||
|
||||
def build_meta(path: Path, body: str, source_text: str = "") -> FileMeta:
|
||||
rel = repo_relative(path)
|
||||
suffix = path.suffix.lower()
|
||||
full_source = source_text if source_text else body
|
||||
created, updated, author = git_created_updated(path, body)
|
||||
rows = git_contributors(path, max(1, len(body.splitlines())), body)
|
||||
if not any(parse_identity(r[0])[0] == ASSISTANT_NAME for r in rows):
|
||||
rows.append((ASSISTANT_NAME, 0, 0))
|
||||
|
||||
pkg = package_line_for_file(full_source, rel, suffix)
|
||||
lang = "java" if suffix in {".java", ".aidl"} else ("php" if suffix == ".php" else "c")
|
||||
|
||||
class_line = detect_class_line(body, suffix)
|
||||
return FileMeta(
|
||||
rel_path=rel,
|
||||
filename=path.name,
|
||||
package_line=pkg,
|
||||
class_line=class_line,
|
||||
created_at=created,
|
||||
updated_at=updated,
|
||||
last_contributor=author,
|
||||
contributors_block=contributors_block(rows),
|
||||
language=lang,
|
||||
)
|
||||
|
||||
|
||||
def render_header(meta: FileMeta, digest_hex: str | None) -> str:
|
||||
lines = [
|
||||
meta.package_line,
|
||||
"",
|
||||
BANNER_START,
|
||||
f" * {meta.filename}",
|
||||
f" * {MARKER_CREATED} {meta.created_at}",
|
||||
f" * {MARKER_UPDATED} {meta.updated_at} by {meta.last_contributor}",
|
||||
]
|
||||
if meta.class_line:
|
||||
lines.append(meta.class_line)
|
||||
lines.append(f" * {MARKER_CONTRIBUTORS}")
|
||||
lines.append(meta.contributors_block)
|
||||
if digest_hex:
|
||||
lines.append(f" * {MARKER_DIGEST} SHA256\t{digest_hex}")
|
||||
lines.append(BANNER_END)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def compute_digest(prefix: str, body: str) -> str:
|
||||
payload = prefix + body
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def apply_header_to_content(path: Path, text: str, update_only: bool = False) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
body, leading = strip_existing_header(text, suffix)
|
||||
meta = build_meta(path, body, text)
|
||||
|
||||
if update_only and MARKER_CREATED in text:
|
||||
m = re.search(r"\* Created at: (.+)", text)
|
||||
if m:
|
||||
meta.created_at = m.group(1).strip()
|
||||
|
||||
prefix_without_digest = render_header(meta, None)
|
||||
digest = compute_digest(prefix_without_digest, body)
|
||||
prefix = render_header(meta, digest)
|
||||
return leading + prefix + body
|
||||
|
||||
|
||||
def has_valid_header(text: str) -> bool:
|
||||
return MARKER_CREATED in text and BANNER_START in text and BANNER_END in text
|
||||
|
||||
|
||||
def header_needs_identity_refresh(text: str, path: Path) -> bool:
|
||||
"""True when a known author appears without email but git has one."""
|
||||
if not has_valid_header(text):
|
||||
return False
|
||||
suffix = path.suffix.lower()
|
||||
body, _ = strip_existing_header(text, suffix)
|
||||
id_map = git_identity_map(path, body)
|
||||
|
||||
updated = re.search(r"\* Updated at: .+ by (.+)$", text, re.MULTILINE)
|
||||
if updated:
|
||||
name, email = parse_identity(updated.group(1).strip())
|
||||
if name != ASSISTANT_NAME and not email and id_map.get(name):
|
||||
return True
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("* - "):
|
||||
continue
|
||||
label = stripped[7:].split(" (", 1)[0].strip()
|
||||
name, email = parse_identity(label)
|
||||
if name != ASSISTANT_NAME and not email and id_map.get(name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_header_timestamps(text: str, path: Path, contributor: str | None = None) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
body, leading = strip_existing_header(text, suffix)
|
||||
if contributor:
|
||||
who = contributor
|
||||
else:
|
||||
who = resolve_header_contributor()
|
||||
if who == ASSISTANT_NAME:
|
||||
who = git_created_updated(path, body)[2]
|
||||
updated_line = datetime.now().astimezone().strftime("%a %d %b %Y %H:%M:%S %z")
|
||||
m_created = re.search(r"\* Created at: (.+)", text)
|
||||
created = m_created.group(1).strip() if m_created else git_created_updated(path)[0]
|
||||
|
||||
meta = build_meta(path, body, text)
|
||||
meta.created_at = created
|
||||
meta.updated_at = updated_line
|
||||
meta.last_contributor = who
|
||||
|
||||
prefix_without_digest = render_header(meta, None)
|
||||
digest = compute_digest(prefix_without_digest, body)
|
||||
prefix = render_header(meta, digest)
|
||||
return leading + prefix + body
|
||||
9
scripts/install-git-hooks.sh
Executable file
9
scripts/install-git-hooks.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install repo git hooks (header pre-commit).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
git -C "$ROOT" config core.hooksPath .githooks
|
||||
chmod +x "$ROOT/.githooks/pre-commit"
|
||||
chmod +x "$ROOT/scripts/header-pre-commit.py"
|
||||
chmod +x "$ROOT/scripts/apply-project-headers.py"
|
||||
echo "Installed hooks via core.hooksPath=.githooks"
|
||||
83
scripts/test_header_lib.py
Executable file
83
scripts/test_header_lib.py
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for header_lib (run: python3 scripts/test_header_lib.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from header_lib import (
|
||||
apply_header_to_content,
|
||||
format_identity,
|
||||
has_valid_header,
|
||||
is_excluded_path,
|
||||
parse_identity,
|
||||
resolve_header_contributor,
|
||||
strip_existing_header,
|
||||
)
|
||||
from pathlib import Path
|
||||
import header_lib
|
||||
|
||||
|
||||
class HeaderLibTest(unittest.TestCase):
|
||||
def test_strip_and_apply_java(self) -> None:
|
||||
src = """package com.example;
|
||||
|
||||
public class Foo {
|
||||
}
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "Foo.java"
|
||||
path.write_text(src, encoding="utf-8")
|
||||
out = apply_header_to_content(path, src)
|
||||
self.assertTrue(has_valid_header(out))
|
||||
self.assertIn("package com.example;", out)
|
||||
self.assertIn(" * class Foo", out)
|
||||
self.assertIn("Digest: SHA256", out)
|
||||
self.assertIn("Cursor Agent", out)
|
||||
body, _ = strip_existing_header(out, ".java")
|
||||
self.assertIn("public class Foo", body)
|
||||
self.assertNotIn("package com.example", body.split("public class")[0])
|
||||
|
||||
def test_strip_php_opener(self) -> None:
|
||||
src = "<?php\ndeclare(strict_types=1);\n"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "bootstrap.php"
|
||||
path.write_text(src, encoding="utf-8")
|
||||
out = apply_header_to_content(path, src)
|
||||
self.assertTrue(out.startswith("<?php\n"))
|
||||
self.assertIn("/* package bootstrap.php */", out)
|
||||
|
||||
|
||||
def test_format_identity(self) -> None:
|
||||
self.assertEqual("Ada <a@b.c>", format_identity("Ada", "a@b.c"))
|
||||
self.assertEqual("Ada", format_identity("Ada", ""))
|
||||
name, mail = parse_identity("Ada <a@b.c>")
|
||||
self.assertEqual(("Ada", "a@b.c"), (name, mail))
|
||||
|
||||
def test_excluded_third_party(self) -> None:
|
||||
p = header_lib.REPO_ROOT / "third-party/libvpx/foo.c"
|
||||
self.assertTrue(is_excluded_path(p))
|
||||
|
||||
def test_resolve_header_contributor_env(self) -> None:
|
||||
import os
|
||||
old = os.environ.get("HEADER_CONTRIBUTOR")
|
||||
old_mail = os.environ.get("HEADER_CONTRIBUTOR_EMAIL")
|
||||
try:
|
||||
os.environ["HEADER_CONTRIBUTOR"] = "Test User"
|
||||
os.environ.pop("HEADER_CONTRIBUTOR_EMAIL", None)
|
||||
ident = resolve_header_contributor()
|
||||
self.assertTrue(ident.startswith("Test User"))
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("HEADER_CONTRIBUTOR", None)
|
||||
else:
|
||||
os.environ["HEADER_CONTRIBUTOR"] = old
|
||||
if old_mail is None:
|
||||
os.environ.pop("HEADER_CONTRIBUTOR_EMAIL", None)
|
||||
else:
|
||||
os.environ["HEADER_CONTRIBUTOR_EMAIL"] = old_mail
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user