1
0

update-keysyms-age.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. #!/usr/bin/env python3
  2. # Copyright © 2026 Pierre Le Marre <dev@wismill.eu>
  3. # SPDX-License-Identifier: MIT
  4. """
  5. Walk a range of commits in a git repository, find every line that was
  6. *added* matching:
  7. #define XKB_KEY_<something>
  8. and record, for each distinct symbol, the first commit that introduced it
  9. and the next git tag that contains that commit. Results are written to a
  10. TOML file.
  11. Usage:
  12. python3 update-keysyms-age.py --repo /path/to/repo \
  13. --range v1.0.0..v1.5.0 \
  14. --path include/xkbcommon/xkbcommon-keysyms.h \
  15. --output xkb_key_defines.csv
  16. If --range is omitted, the full history (all commits reachable from HEAD)
  17. is scanned. If --path is omitted, the whole repository is scanned.
  18. Incremental / resume mode:
  19. If --output already points to an existing TOML file *and* --range was
  20. NOT given, the script resumes instead of rescanning everything: it
  21. reads the entry of the existing TOML, starts scanning from the
  22. commit right after that row's first_commit through HEAD, and appends
  23. only the newly found symbols to the file (no header rewritten, no
  24. existing rows touched). This makes it cheap to re-run periodically to
  25. pick up newly added symbols.
  26. To force a full rescan even if the output file exists, either delete
  27. the output file first or pass an explicit --range.
  28. """
  29. import argparse
  30. import os
  31. import re
  32. import subprocess
  33. import sys
  34. from collections import defaultdict
  35. from pathlib import Path
  36. from typing import Any
  37. import tomllib
  38. from packaging.version import Version
  39. SCRIPT = Path(__file__)
  40. ROOT = SCRIPT.parent.parent
  41. HEADER = ROOT / "include/xkbcommon/xkbcommon-keysyms.h"
  42. OUTPUT = ROOT / "data/keysyms/age.toml"
  43. TAG_PREFIX = "xkbcommon-"
  44. # Matches a diff "added line" of the form: +#define XKB_KEY_foo ...
  45. MACRO_PATTERN = re.compile(r"^\+\s*#define\s+XKB_KEY_(\w+)")
  46. # Matches a commit header line in `git log -p` output: "commit <hash>"
  47. COMMIT_PATTERN = re.compile(r"^commit ([0-9a-f]{7,40})")
  48. def run_git(args: list[str], cwd: Path):
  49. try:
  50. result = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
  51. except OSError as e:
  52. raise RuntimeError(f"could not run git in '{cwd}': {e}")
  53. if result.returncode != 0:
  54. raise RuntimeError(
  55. "git {} failed:\n{}".format(" ".join(args), result.stderr.strip())
  56. )
  57. return result.stdout
  58. def get_branch_root_commit(repo: Path) -> str:
  59. return run_git(["hash-object", "-t", "tree", "/dev/null"], repo).strip()
  60. def get_log_text(repo: Path, rev_range: str, path: Path):
  61. """
  62. Fetch full patches (in chronological order, oldest first) for every
  63. commit that touched a line matching '#define XKB_KEY_'. We filter to
  64. only *added* lines ourselves afterwards, since -G matches any change
  65. (add or remove) to a matching line.
  66. """
  67. args = [
  68. "log",
  69. "--reverse",
  70. "-p",
  71. "--no-color",
  72. "--no-decorate",
  73. "-G",
  74. r"#define\s+XKB_KEY_",
  75. ]
  76. if rev_range:
  77. args.append(rev_range)
  78. if path:
  79. args += ["--", str(path)]
  80. return run_git(args, repo)
  81. def parse_log(log_text):
  82. """
  83. Returns an ordered dict: macro_name -> first_commit_hash
  84. (first = earliest commit in the scanned range that added the line).
  85. """
  86. results = {}
  87. current_commit = None
  88. for line in log_text.splitlines():
  89. m = COMMIT_PATTERN.match(line)
  90. if m:
  91. current_commit = m.group(1)
  92. continue
  93. m = MACRO_PATTERN.match(line)
  94. if m and current_commit:
  95. name = m.group(1)
  96. if name not in results:
  97. results[name] = current_commit
  98. return results
  99. def get_lib_version(repo: Path, commit: str, rev_range: str | None) -> Version | None:
  100. """
  101. Returns the nearest tag that contains `commit` (i.e. the first tag
  102. reachable from `commit`), or '' if no tag contains it (e.g. it hasn't
  103. been released yet).
  104. """
  105. candidates = set(
  106. t
  107. for t in run_git(["tag", "--contains", commit], repo).splitlines()
  108. if t.startswith(TAG_PREFIX)
  109. )
  110. if not candidates:
  111. return None
  112. for r in (rev_range, f"{commit}..HEAD"):
  113. if not r:
  114. continue
  115. _, end = r.split("..")
  116. backup = set(candidates)
  117. for tag in backup:
  118. try:
  119. run_git(["merge-base", "--is-ancestor", tag, end], repo)
  120. except RuntimeError:
  121. candidates.remove(tag)
  122. if candidates:
  123. break
  124. else:
  125. candidates = backup
  126. results: list[tuple[int, Version]] = sorted(
  127. (
  128. int(run_git(["rev-list", "--count", f"{commit}..{tag}"], repo).strip()),
  129. Version(tag.removeprefix(TAG_PREFIX)),
  130. )
  131. for tag in candidates
  132. )
  133. if not results:
  134. return None
  135. elif results[0][1].is_prerelease:
  136. # Pre-release: check for next non-prerelease tag
  137. for _, version in results[1:]:
  138. if not version.is_prerelease:
  139. return version
  140. return results[0][1]
  141. def read_existing_output(path: Path) -> tuple[str, set[str]]:
  142. """
  143. Read a previously-generated TOML, if any.
  144. Returns (last_commit, existing_symbols):
  145. - last_commit: first_commit value of the LAST data row in the file,
  146. or None if the file has no data rows.
  147. - existing_symbols: set of symbol names already present, so we never
  148. write a duplicate row.
  149. """
  150. existing_symbols: set[str] = set()
  151. last_commit: str = ""
  152. with path.open("rb") as f:
  153. commits = tomllib.load(f)
  154. if not commits:
  155. return "", existing_symbols
  156. for last_commit, entry in commits.items():
  157. existing_symbols.update(entry["names"])
  158. return last_commit, existing_symbols
  159. def main():
  160. parser = argparse.ArgumentParser(
  161. description=(
  162. 'Scan a git commit range for added "#define XKB_KEY_*" lines '
  163. "and record the first commit + next tag for each symbol."
  164. )
  165. )
  166. parser.add_argument(
  167. "--repo",
  168. type=Path,
  169. default=Path("."),
  170. help="Path to the git repo (default: current dir)",
  171. )
  172. parser.add_argument(
  173. "--range",
  174. default=None,
  175. help='Commit range, e.g. "xkbcommon-1.0.0..xkbcommon-1.5.0" or "abc123..HEAD". '
  176. "Omit to scan the whole history reachable from HEAD (or, if "
  177. "--output already exists, to resume from where it left off).",
  178. )
  179. parser.add_argument(
  180. "--path",
  181. type=Path,
  182. default=HEADER,
  183. help="Restrict the scan to a specific file or path within the repo. (default: %(default)s)",
  184. )
  185. parser.add_argument(
  186. "--output",
  187. type=Path,
  188. default=OUTPUT,
  189. help="Output TOML file path (default: %(default)s)",
  190. )
  191. args = parser.parse_args()
  192. # Decide whether we're resuming an existing CSV or doing a fresh scan.
  193. appending = False
  194. existing_symbols: set[str] = set()
  195. effective_range = args.range
  196. if (
  197. args.range is None
  198. and os.path.exists(args.output)
  199. and os.path.getsize(args.output) > 0
  200. ):
  201. try:
  202. last_commit, existing_symbols = read_existing_output(args.output)
  203. except OSError as e:
  204. print(
  205. f"Error reading existing output '{args.output}': {e}", file=sys.stderr
  206. )
  207. sys.exit(1)
  208. if last_commit:
  209. effective_range = f"{last_commit}..HEAD"
  210. appending = True
  211. print(
  212. f"Resuming: found existing '{args.output}', scanning {effective_range}",
  213. file=sys.stderr,
  214. )
  215. else:
  216. print(
  217. f"'{args.output}' exists but has no data; doing a full scan.",
  218. file=sys.stderr,
  219. )
  220. elif effective_range is not None:
  221. if effective_range.startswith(".."):
  222. effective_range = get_branch_root_commit(args.repo) + effective_range
  223. if effective_range.endswith(".."):
  224. effective_range += "HEAD"
  225. try:
  226. log_text = get_log_text(args.repo, effective_range, args.path)
  227. except RuntimeError as e:
  228. print(f"Error: {e}", file=sys.stderr)
  229. sys.exit(1)
  230. results = parse_log(log_text)
  231. new_results: dict[str, list[str]] = defaultdict(list)
  232. for name, commit in results.items():
  233. if name not in existing_symbols:
  234. new_results[commit].append(name)
  235. if not new_results:
  236. print("No new '#define XKB_KEY_*' found.", file=sys.stderr)
  237. entries: dict[str, dict[str, Any]] = {}
  238. for commit, names in new_results.items():
  239. version = get_lib_version(args.repo, commit, effective_range)
  240. if not version:
  241. print(
  242. f"WARNING: cannot find tag for commit {commit}. Skip names: {','.join(names)}",
  243. file=sys.stderr,
  244. )
  245. continue
  246. entries[commit] = {"version": version, "names": names}
  247. if entries:
  248. file_mode = "at" if appending else "wt"
  249. with args.output.open(file_mode, encoding="utf-8") as f:
  250. for k, (commit, entry) in enumerate(entries.items()):
  251. if k > 0 or (k == 0 and existing_symbols):
  252. f.write("\n")
  253. f.write(f"[{commit}]\n")
  254. version = entry["version"]
  255. version1 = Version("1.0.0")
  256. if version < version1:
  257. # HACK
  258. comment = f" # real: {version}"
  259. version = version1
  260. else:
  261. comment = ""
  262. f.write(f'version = "{version}"{comment}\n')
  263. f.write("names = [")
  264. names = list(f'"{n}"' for n in entry["names"])
  265. if len(names) > 1:
  266. f.write(f"\n\t{',\n\t'.join(names)}\n")
  267. else:
  268. f.write(", ".join(names))
  269. f.write("]\n")
  270. count = sum(len(e["names"]) for e in entries.values())
  271. verb = "Appended" if appending else "Wrote"
  272. print(f"{verb} {count} keysym name(s) to {args.output}", file=sys.stderr)
  273. else:
  274. print("No keysym names to add", file=sys.stderr)
  275. if __name__ == "__main__":
  276. main()