mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:18:32 +03:00
639 lines
21 KiB
Python
Executable File
639 lines
21 KiB
Python
Executable File
#!/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_COMMIT = "Commit:"
|
|
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
|
|
commit_sha: 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 hook_skip_requested() -> bool:
|
|
import os
|
|
|
|
return os.environ.get("HEADER_HOOK_SKIP", "").strip() in {"1", "true", "yes"}
|
|
|
|
|
|
def git_head_sha() -> str:
|
|
return run_git(["rev-parse", "HEAD"], REPO_ROOT)
|
|
|
|
|
|
def git_last_commit_sha_for_file(path: Path) -> str:
|
|
rel = repo_relative(path)
|
|
return run_git(["log", "-1", "--format=%H", "--", rel], REPO_ROOT)
|
|
|
|
|
|
def parse_commit_from_header(text: str) -> str:
|
|
m = re.search(r"\* Commit:\s*(\S+)", text)
|
|
return m.group(1).strip() if m else ""
|
|
|
|
|
|
def files_in_git_commit(commit: str = "HEAD") -> list[Path]:
|
|
out = run_git(
|
|
["diff-tree", "--no-commit-id", "--name-only", "-r", commit],
|
|
REPO_ROOT,
|
|
)
|
|
paths: list[Path] = []
|
|
for line in out.splitlines():
|
|
rel = line.strip()
|
|
if not rel:
|
|
continue
|
|
p = REPO_ROOT / rel
|
|
if not p.is_file() or p.suffix.lower() not in SOURCE_SUFFIXES:
|
|
continue
|
|
if is_excluded_path(p):
|
|
continue
|
|
paths.append(p)
|
|
return paths
|
|
|
|
|
|
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_php(text: str) -> str:
|
|
"""Remove <?php, block-comment header(s), and closing ?> before real body."""
|
|
work = text.lstrip("\ufeff")
|
|
while True:
|
|
changed = False
|
|
w = work.lstrip()
|
|
if w.startswith("<?php"):
|
|
work = w[5:].lstrip("\n")
|
|
changed = True
|
|
w = work.lstrip()
|
|
if w.startswith("?>"):
|
|
work = w[2:].lstrip("\n")
|
|
changed = True
|
|
w = work.lstrip()
|
|
if w.startswith("/*") or w.startswith("/*****"):
|
|
close = w.find("*/")
|
|
if close >= 0:
|
|
work = w[close + 2 :].lstrip("\n")
|
|
changed = True
|
|
else:
|
|
break
|
|
if not changed:
|
|
break
|
|
return work.lstrip("\n")
|
|
|
|
|
|
def php_body_is_markup(body: str) -> bool:
|
|
"""True when body is HTML/templates after the commented header (needs ?>)."""
|
|
s = body.lstrip()
|
|
return bool(s) and s[0] == "<"
|
|
|
|
|
|
def strip_existing_header(text: str, suffix: str) -> tuple[str, str]:
|
|
"""Returns (body without header/package, preserved leading e.g. <?php)."""
|
|
if suffix == ".php":
|
|
return strip_existing_header_php(text), ""
|
|
|
|
leading = ""
|
|
work = text
|
|
|
|
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)
|
|
commit_sha = parse_commit_from_header(source_text) or git_last_commit_sha_for_file(path)
|
|
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,
|
|
commit_sha=commit_sha,
|
|
)
|
|
|
|
|
|
def _inner_header_lines(meta: FileMeta, *, for_digest: bool) -> list[str]:
|
|
"""Banner lines shared by PHP and C-style headers."""
|
|
lines = [
|
|
f" * {meta.filename}",
|
|
f" * {MARKER_CREATED} {meta.created_at}",
|
|
f" * {MARKER_UPDATED} {meta.updated_at} by {meta.last_contributor}",
|
|
]
|
|
if not for_digest and meta.commit_sha:
|
|
lines.append(f" * {MARKER_COMMIT} {meta.commit_sha}")
|
|
if meta.class_line:
|
|
lines.append(meta.class_line)
|
|
lines.append(f" * {MARKER_CONTRIBUTORS}")
|
|
lines.extend(meta.contributors_block.splitlines())
|
|
return lines
|
|
|
|
|
|
def render_php_header(meta: FileMeta, digest_hex: str | None, *, for_digest: bool = False) -> str:
|
|
"""
|
|
PHP: header is one block comment inside <?php ... ?> so the engine ignores it.
|
|
No nested '/***** ... */' banners (inner */ would break the comment).
|
|
"""
|
|
inner = [f" * package {meta.rel_path}"]
|
|
inner.extend(_inner_header_lines(meta, for_digest=for_digest))
|
|
if digest_hex and not for_digest:
|
|
inner.append(f" * {MARKER_DIGEST} SHA256\t{digest_hex}")
|
|
return "<?php\n/*\n" + "\n".join(inner) + "\n */\n"
|
|
|
|
|
|
def render_header(
|
|
meta: FileMeta, digest_hex: str | None, suffix: str = "", *, for_digest: bool = False) -> str:
|
|
if suffix == ".php" or meta.language == "php":
|
|
return render_php_header(meta, digest_hex, for_digest=for_digest)
|
|
lines = [
|
|
meta.package_line,
|
|
"",
|
|
BANNER_START,
|
|
*_inner_header_lines(meta, for_digest=for_digest),
|
|
]
|
|
if digest_hex and not for_digest:
|
|
lines.append(f" * {MARKER_DIGEST} SHA256\t{digest_hex}")
|
|
lines.append(BANNER_END)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def wrap_php_header_and_body(header: str, body: str) -> str:
|
|
"""
|
|
Pure PHP: <?php /* header */ code... (declare stays first statement after <?php).
|
|
Templates: <?php /* header */ ?> then HTML.
|
|
"""
|
|
body = body.lstrip("\n")
|
|
if body.startswith("<?php"):
|
|
body = body[5:].lstrip("\n")
|
|
if php_body_is_markup(body):
|
|
return header + "?>\n" + body
|
|
return header + body
|
|
|
|
|
|
def compute_digest(header_prefix: str, body: str) -> str:
|
|
"""Hash file body + header without Digest/Commit lines."""
|
|
payload = header_prefix + body
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def finalize_header(meta: FileMeta, body: str, suffix: str, leading: str = "") -> str:
|
|
prefix_for_digest = render_header(meta, None, suffix, for_digest=True)
|
|
digest = compute_digest(prefix_for_digest, body)
|
|
prefix = render_header(meta, digest, suffix, for_digest=False)
|
|
if suffix == ".php":
|
|
return wrap_php_header_and_body(prefix, body)
|
|
return leading + prefix + body
|
|
|
|
|
|
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()
|
|
|
|
return finalize_header(meta, body, suffix, leading)
|
|
|
|
|
|
def has_valid_header(text: str) -> bool:
|
|
if MARKER_CREATED not in text:
|
|
return False
|
|
if text.lstrip().startswith("<?php") and "/*" in text[:800]:
|
|
return "*/" in text[:1200]
|
|
return 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, *, touch_commit: bool = False) -> 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
|
|
if touch_commit:
|
|
meta.commit_sha = git_head_sha()
|
|
else:
|
|
meta.commit_sha = parse_commit_from_header(text)
|
|
|
|
return finalize_header(meta, body, suffix, leading)
|
|
|
|
|
|
def update_header_commit(text: str, path: Path, commit_sha: str) -> str:
|
|
"""Set Commit: line after post-commit; recomputes Digest (Commit excluded from hash)."""
|
|
if parse_commit_from_header(text) == commit_sha:
|
|
return text
|
|
suffix = path.suffix.lower()
|
|
body, leading = strip_existing_header(text, suffix)
|
|
meta = build_meta(path, body, text)
|
|
m_created = re.search(r"\* Created at: (.+)", text)
|
|
m_updated = re.search(r"\* Updated at: (.+?) by (.+)$", text, re.MULTILINE)
|
|
if m_created:
|
|
meta.created_at = m_created.group(1).strip()
|
|
if m_updated:
|
|
meta.updated_at = m_updated.group(1).strip()
|
|
meta.last_contributor = m_updated.group(2).strip()
|
|
meta.commit_sha = commit_sha
|
|
return finalize_header(meta, body, suffix, leading)
|