new file mode 100755
@@ -0,0 +1,316 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-or-later
+# Copyright (C) 2026, Red Hat Inc.
+#
+# Author: Ray Strode <rstrode@redhat.com>
+#
+# Find libcamera maintainers and reviewers from local Git history.
+
+import argparse
+import subprocess
+import sys
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from email.headerregistry import Address
+from email.utils import parseaddr
+from pathlib import Path, PurePosixPath
+
+
+MAILING_LIST = 'libcamera-devel@lists.libcamera.org'
+
+
+def git(directory, *args, input=None):
+ """Run a read-only Git command, keeping diagnostics off standard output."""
+ result = subprocess.run(['git', '--no-pager', '--literal-pathspecs',
+ '-C', str(directory), *args],
+ input=input, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ if result.returncode:
+ raise ValueError(result.stderr.decode(errors='replace').strip())
+ return result.stdout.decode('utf-8', errors='surrogateescape')
+
+
+def repository():
+ try:
+ root = git(Path.cwd(), 'rev-parse', '--show-toplevel')
+ except ValueError:
+ root = git(Path(__file__).resolve().parent, 'rev-parse',
+ '--show-toplevel')
+ return Path(root.strip()).resolve()
+
+
+def default_revision(root):
+ try:
+ return git(root, 'rev-parse', '--symbolic-full-name',
'@{upstream}').strip()
+ except ValueError:
+ return 'HEAD'
+
+
+def file_path(root, name, revision):
+ # Resolve the parent so that a tracked symlink is treated as a file, even
+ # when its target is outside the repository.
+ path = Path(name).absolute()
+ path = path.parent.resolve() / path.name
+ try:
+ relative = path.relative_to(root).as_posix()
+ except ValueError:
+ raise ValueError(f'{name}: path is outside the repository') from None
+
+ if not path.exists() and not git(root, 'ls-tree', revision, '--',
relative):
+ raise ValueError(f'{name}: no such file in the worktree or
history tip')
+ return relative or '.'
+
+
+def patch_paths(root, data):
+ """Let Git parse filenames, including quoted names, renames and
deletions."""
+ paths = set()
+ # Forward statistics name a rename's destination; reverse statistics name
+ # its source. Neither command applies the patch or changes the worktree.
+ for direction in ((), ('--reverse',)):
+ output = git(root, 'apply', '--numstat', '-z', *direction, input=data)
+ for record in output.split('\0'):
+ if not record:
+ continue
+ name = record.split('\t', 2)[2]
+ path = PurePosixPath(name)
+ if path.is_absolute() or '..' in path.parts:
+ raise ValueError(f'{name}: patch path is outside the
repository')
+ paths.add(path.as_posix())
+ if not paths:
+ raise ValueError('patch contains no changed paths')
+ return paths
+
+
+@dataclass
+class Commit:
+ oid: str
+ timestamp: int
+ author: str
+ reviewers: set
+ ackers: set
+
+
+def history(root, revision, since, path):
+ # Ask Git to parse trailers; prose containing "Reviewed-by" is
not a credit.
+ # NUL separators keep the variable-length fields unambiguous.
+ fmt = ('%H%x00%ct%x00%an <%ae>%x00'
+ '%(trailers:key=Reviewed-by,valueonly,unfold)%x00'
+ '%(trailers:key=Acked-by,valueonly,unfold)')
+ output = git(root, 'log', '--no-merges', '--no-notes',
+ '--no-show-signature', '-z', f'--format={fmt}',
+ f'--since-as-filter={since}', revision, '--', path)
+ fields = output.split('\0')[:-1]
+ commits = []
+ for offset in range(0, len(fields), 5):
+ oid, timestamp, author, reviews, acks = fields[offset:offset + 5]
+ commits.append(Commit(oid, int(timestamp), author,
+ set(reviews.splitlines()),
set(acks.splitlines())))
+ return commits
+
+
+def identities(root, commits, use_mailmap):
+ """Canonicalize authors and trailer identities together, once per
address."""
+ names = set()
+ for commit in commits:
+ names.update({commit.author} | commit.reviewers | commit.ackers)
+ names = sorted(names)
+ if use_mailmap and names:
+ data = ('\n'.join(names) + '\n').encode('utf-8',
errors='surrogateescape')
+ canonical = git(root, 'check-mailmap', '--stdin',
input=data).splitlines()
+ else:
+ canonical = names
+
+ result = {}
+ for original, mapped in zip(names, canonical):
+ name, address = parseaddr(mapped)
+ if '@' in address:
+ result[original] = (name, address.lower())
+ return result
+
+
+@dataclass
+class Contact:
+ name: str
+ email: str
+ reviews: set = field(default_factory=set)
+ acks: set = field(default_factory=set)
+ authorship: set = field(default_factory=set)
+ latest: int = 0
+
+ def rank(self):
+ # Review experience comes first. Authorship supplies
additional contacts
+ # and breaks ties; the date is the matching commit's date, not a review
+ # timestamp. Each commit contributes at most once to the review count.
+ return (-len(self.reviews | self.acks), -len(self.reviews),
+ -len(self.authorship), -self.latest, self.email)
+
+ def description(self, total, stats):
+ roles = []
+ for role, commits in (('reviewer', self.reviews), ('acker', self.acks),
+ ('author', self.authorship)):
+ if not commits:
+ continue
+ if stats:
+ roles.append(f'{role}:{len(commits)}/{total}'
+ f'={len(commits) * 100 / total:.0f}%')
+ else:
+ roles.append(role)
+ if stats:
+ date = datetime.fromtimestamp(self.latest, timezone.utc).date()
+ roles.append(f'last-commit:{date}')
+ return ', '.join(roles)
+
+
+def contacts(commits, names):
+ people = {}
+ for commit in commits:
+ for members, role in (({commit.author}, 'authorship'),
+ (commit.reviewers, 'reviews'),
+ (commit.ackers, 'acks')):
+ for identity in members:
+ if identity not in names:
+ continue
+ name, email = names[identity]
+ person = people.setdefault(email, Contact(name, email))
+ if commit.timestamp > person.latest:
+ person.name = name
+ person.latest = commit.timestamp
+ getattr(person, role).add(commit.oid)
+ return people
+
+
+def select_contacts(people, total, args):
+ selected = []
+ for person in sorted(people.values(), key=Contact.rank):
+ count = len(person.reviews | person.acks | person.authorship)
+ if count < args.git_min_signatures:
+ continue
+ if count * 100 < args.git_min_percent * total:
+ continue
+ selected.append(person.email)
+ if len(selected) == args.git_max_maintainers:
+ break
+ return selected
+
+
+def add_boolean_argument(parser, name, default, help_text):
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument(f'--{name}', dest=name, action='store_true',
help=help_text)
+ group.add_argument(f'--no{name}', f'--no-{name}', dest=name,
+ action='store_false', help=f'disable --{name}')
+ parser.set_defaults(**{name: default})
+
+
+def parse_arguments(argv):
+ parser = argparse.ArgumentParser(
+ description='Find maintainers and reviewers from local Git history.',
+ usage='%(prog)s [options] patchfile ...\n'
+ ' %(prog)s [options] -f file-or-directory ...',
+ epilog='Reads a patch from stdin when no patchfile is given;
use - for '
+ 'stdin explicitly. History is enabled by default. Contacts are '
+ 'selected using Reviewed-by, Acked-by and authorship.')
+ parser.add_argument('inputs', nargs='*', help='patch files, or
paths with -f')
+ parser.add_argument('-f', '--file', action='store_true',
+ help='interpret positional arguments as
repository paths')
+ parser.add_argument('--git', action='store_true',
+ help='use Git history (enabled by default)')
+ parser.add_argument('--email', action='store_true',
+ help='print email addresses (enabled by default)')
+ parser.add_argument('--git-since', default='1-year-ago', metavar='DATE',
+ help='Git date limiting history (default: 1-year-ago)')
+ parser.add_argument('--git-revision', metavar='REF',
+ help='history tip (default: branch upstream, or HEAD)')
+ parser.add_argument('--git-min-signatures', type=int, default=1,
metavar='N',
+ help='minimum distinct matching commits (default: 1)')
+ parser.add_argument('--git-min-percent', type=float, default=5,
metavar='PCT',
+ help='minimum share of matching commits (default: 5)')
+ parser.add_argument('--git-max-maintainers', type=int, default=5,
metavar='N',
+ help='maximum suggestions per input path (default: 5)')
+ add_boolean_argument(parser, 'mailmap', True, 'normalize
identities with Git mailmap')
+ add_boolean_argument(parser, 'roles', True, 'annotate each
address with its roles')
+ add_boolean_argument(parser, 'rolestats', True, 'include commit
counts and last commit date')
+ add_boolean_argument(parser, 'n', True, 'include names with email
addresses')
+ add_boolean_argument(parser, 'l', True, 'include the
libcamera-devel mailing list')
+ args = parser.parse_intermixed_args(argv[1:])
+ if args.file and not args.inputs:
+ parser.error('-f requires at least one file or directory')
+ if args.git_min_signatures < 1 or args.git_max_maintainers < 1:
+ parser.error('commit and contact limits must be positive')
+ if not 0 <= args.git_min_percent <= 100:
+ parser.error('--git-min-percent must be between 0 and 100')
+ if not args.inputs and sys.stdin.isatty():
+ parser.error('provide a patchfile, stdin, or -f file-or-directory')
+ return args
+
+
+def main(argv):
+ args = parse_arguments(argv)
+ try:
+ root = repository()
+ ref = args.git_revision or default_revision(root)
+ revision = git(root, 'rev-parse', '--verify', '--end-of-options',
+ f'{ref}^{{commit}}').strip()
+ if args.file:
+ paths = {file_path(root, name, revision) for name in args.inputs}
+ else:
+ paths = set()
+ for name in args.inputs or ['-']:
+ if name == '-':
+ data = sys.stdin.buffer.read()
+ else:
+ data = Path(name).read_bytes()
+ paths.update(patch_paths(root, data))
+
+ if git(root, 'rev-parse', '--is-shallow-repository').strip() == 'true':
+ print('warning: shallow repository; reviewer history is
incomplete',
+ file=sys.stderr)
+
+ scopes = []
+ for path in sorted(paths):
+ commits = history(root, revision, args.git_since, path)
+ if not commits:
+ parent = PurePosixPath(path).parent.as_posix()
+ # An immediate parent can supply contacts for a new file. Never
+ # widen a query to the entire repository implicitly.
+ if parent != '.':
+ print(f'No recent history for {path}; checking {parent}/',
+ file=sys.stderr)
+ commits = history(root, revision, args.git_since, parent)
+ if not commits:
+ print(f'No recent history for {path}', file=sys.stderr)
+ scopes.append(commits)
+
+ all_commits = {commit.oid: commit for scope in scopes for
commit in scope}
+ names = identities(root, all_commits.values(), args.mailmap)
+ selected = set()
+ for commits in scopes:
+ selected.update(select_contacts(contacts(commits, names),
+ len(commits), args))
+
+ # Select per path so a busy subsystem cannot crowd out a quiet one.
+ # Merge evidence by commit ID before printing, so overlapping paths