| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- #!/usr/bin/env python3
- # Copyright © 2026 Pierre Le Marre <dev@wismill.eu>
- # SPDX-License-Identifier: MIT
- """
- Walk a range of commits in a git repository, find every line that was
- *added* matching:
- #define XKB_KEY_<something>
- and record, for each distinct symbol, the first commit that introduced it
- and the next git tag that contains that commit. Results are written to a
- TOML file.
- Usage:
- python3 update-keysyms-age.py --repo /path/to/repo \
- --range v1.0.0..v1.5.0 \
- --path include/xkbcommon/xkbcommon-keysyms.h \
- --output xkb_key_defines.csv
- If --range is omitted, the full history (all commits reachable from HEAD)
- is scanned. If --path is omitted, the whole repository is scanned.
- Incremental / resume mode:
- If --output already points to an existing TOML file *and* --range was
- NOT given, the script resumes instead of rescanning everything: it
- reads the entry of the existing TOML, starts scanning from the
- commit right after that row's first_commit through HEAD, and appends
- only the newly found symbols to the file (no header rewritten, no
- existing rows touched). This makes it cheap to re-run periodically to
- pick up newly added symbols.
- To force a full rescan even if the output file exists, either delete
- the output file first or pass an explicit --range.
- """
- import argparse
- import os
- import re
- import subprocess
- import sys
- from collections import defaultdict
- from pathlib import Path
- from typing import Any
- import tomllib
- from packaging.version import Version
- SCRIPT = Path(__file__)
- ROOT = SCRIPT.parent.parent
- HEADER = ROOT / "include/xkbcommon/xkbcommon-keysyms.h"
- OUTPUT = ROOT / "data/keysyms/age.toml"
- TAG_PREFIX = "xkbcommon-"
- # Matches a diff "added line" of the form: +#define XKB_KEY_foo ...
- MACRO_PATTERN = re.compile(r"^\+\s*#define\s+XKB_KEY_(\w+)")
- # Matches a commit header line in `git log -p` output: "commit <hash>"
- COMMIT_PATTERN = re.compile(r"^commit ([0-9a-f]{7,40})")
- def run_git(args: list[str], cwd: Path):
- try:
- result = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
- except OSError as e:
- raise RuntimeError(f"could not run git in '{cwd}': {e}")
- if result.returncode != 0:
- raise RuntimeError(
- "git {} failed:\n{}".format(" ".join(args), result.stderr.strip())
- )
- return result.stdout
- def get_branch_root_commit(repo: Path) -> str:
- return run_git(["hash-object", "-t", "tree", "/dev/null"], repo).strip()
- def get_log_text(repo: Path, rev_range: str, path: Path):
- """
- Fetch full patches (in chronological order, oldest first) for every
- commit that touched a line matching '#define XKB_KEY_'. We filter to
- only *added* lines ourselves afterwards, since -G matches any change
- (add or remove) to a matching line.
- """
- args = [
- "log",
- "--reverse",
- "-p",
- "--no-color",
- "--no-decorate",
- "-G",
- r"#define\s+XKB_KEY_",
- ]
- if rev_range:
- args.append(rev_range)
- if path:
- args += ["--", str(path)]
- return run_git(args, repo)
- def parse_log(log_text):
- """
- Returns an ordered dict: macro_name -> first_commit_hash
- (first = earliest commit in the scanned range that added the line).
- """
- results = {}
- current_commit = None
- for line in log_text.splitlines():
- m = COMMIT_PATTERN.match(line)
- if m:
- current_commit = m.group(1)
- continue
- m = MACRO_PATTERN.match(line)
- if m and current_commit:
- name = m.group(1)
- if name not in results:
- results[name] = current_commit
- return results
- def get_lib_version(repo: Path, commit: str, rev_range: str | None) -> Version | None:
- """
- Returns the nearest tag that contains `commit` (i.e. the first tag
- reachable from `commit`), or '' if no tag contains it (e.g. it hasn't
- been released yet).
- """
- candidates = set(
- t
- for t in run_git(["tag", "--contains", commit], repo).splitlines()
- if t.startswith(TAG_PREFIX)
- )
- if not candidates:
- return None
- for r in (rev_range, f"{commit}..HEAD"):
- if not r:
- continue
- _, end = r.split("..")
- backup = set(candidates)
- for tag in backup:
- try:
- run_git(["merge-base", "--is-ancestor", tag, end], repo)
- except RuntimeError:
- candidates.remove(tag)
- if candidates:
- break
- else:
- candidates = backup
- results: list[tuple[int, Version]] = sorted(
- (
- int(run_git(["rev-list", "--count", f"{commit}..{tag}"], repo).strip()),
- Version(tag.removeprefix(TAG_PREFIX)),
- )
- for tag in candidates
- )
- if not results:
- return None
- elif results[0][1].is_prerelease:
- # Pre-release: check for next non-prerelease tag
- for _, version in results[1:]:
- if not version.is_prerelease:
- return version
- return results[0][1]
- def read_existing_output(path: Path) -> tuple[str, set[str]]:
- """
- Read a previously-generated TOML, if any.
- Returns (last_commit, existing_symbols):
- - last_commit: first_commit value of the LAST data row in the file,
- or None if the file has no data rows.
- - existing_symbols: set of symbol names already present, so we never
- write a duplicate row.
- """
- existing_symbols: set[str] = set()
- last_commit: str = ""
- with path.open("rb") as f:
- commits = tomllib.load(f)
- if not commits:
- return "", existing_symbols
- for last_commit, entry in commits.items():
- existing_symbols.update(entry["names"])
- return last_commit, existing_symbols
- def main():
- parser = argparse.ArgumentParser(
- description=(
- 'Scan a git commit range for added "#define XKB_KEY_*" lines '
- "and record the first commit + next tag for each symbol."
- )
- )
- parser.add_argument(
- "--repo",
- type=Path,
- default=Path("."),
- help="Path to the git repo (default: current dir)",
- )
- parser.add_argument(
- "--range",
- default=None,
- help='Commit range, e.g. "xkbcommon-1.0.0..xkbcommon-1.5.0" or "abc123..HEAD". '
- "Omit to scan the whole history reachable from HEAD (or, if "
- "--output already exists, to resume from where it left off).",
- )
- parser.add_argument(
- "--path",
- type=Path,
- default=HEADER,
- help="Restrict the scan to a specific file or path within the repo. (default: %(default)s)",
- )
- parser.add_argument(
- "--output",
- type=Path,
- default=OUTPUT,
- help="Output TOML file path (default: %(default)s)",
- )
- args = parser.parse_args()
- # Decide whether we're resuming an existing CSV or doing a fresh scan.
- appending = False
- existing_symbols: set[str] = set()
- effective_range = args.range
- if (
- args.range is None
- and os.path.exists(args.output)
- and os.path.getsize(args.output) > 0
- ):
- try:
- last_commit, existing_symbols = read_existing_output(args.output)
- except OSError as e:
- print(
- f"Error reading existing output '{args.output}': {e}", file=sys.stderr
- )
- sys.exit(1)
- if last_commit:
- effective_range = f"{last_commit}..HEAD"
- appending = True
- print(
- f"Resuming: found existing '{args.output}', scanning {effective_range}",
- file=sys.stderr,
- )
- else:
- print(
- f"'{args.output}' exists but has no data; doing a full scan.",
- file=sys.stderr,
- )
- elif effective_range is not None:
- if effective_range.startswith(".."):
- effective_range = get_branch_root_commit(args.repo) + effective_range
- if effective_range.endswith(".."):
- effective_range += "HEAD"
- try:
- log_text = get_log_text(args.repo, effective_range, args.path)
- except RuntimeError as e:
- print(f"Error: {e}", file=sys.stderr)
- sys.exit(1)
- results = parse_log(log_text)
- new_results: dict[str, list[str]] = defaultdict(list)
- for name, commit in results.items():
- if name not in existing_symbols:
- new_results[commit].append(name)
- if not new_results:
- print("No new '#define XKB_KEY_*' found.", file=sys.stderr)
- entries: dict[str, dict[str, Any]] = {}
- for commit, names in new_results.items():
- version = get_lib_version(args.repo, commit, effective_range)
- if not version:
- print(
- f"WARNING: cannot find tag for commit {commit}. Skip names: {','.join(names)}",
- file=sys.stderr,
- )
- continue
- entries[commit] = {"version": version, "names": names}
- if entries:
- file_mode = "at" if appending else "wt"
- with args.output.open(file_mode, encoding="utf-8") as f:
- for k, (commit, entry) in enumerate(entries.items()):
- if k > 0 or (k == 0 and existing_symbols):
- f.write("\n")
- f.write(f"[{commit}]\n")
- version = entry["version"]
- version1 = Version("1.0.0")
- if version < version1:
- # HACK
- comment = f" # real: {version}"
- version = version1
- else:
- comment = ""
- f.write(f'version = "{version}"{comment}\n')
- f.write("names = [")
- names = list(f'"{n}"' for n in entry["names"])
- if len(names) > 1:
- f.write(f"\n\t{',\n\t'.join(names)}\n")
- else:
- f.write(", ".join(names))
- f.write("]\n")
- count = sum(len(e["names"]) for e in entries.values())
- verb = "Appended" if appending else "Wrote"
- print(f"{verb} {count} keysym name(s) to {args.output}", file=sys.stderr)
- else:
- print("No keysym names to add", file=sys.stderr)
- if __name__ == "__main__":
- main()
|