diff --git a/utils/get_maintainer.py b/utils/get_maintainer.py
new file mode 100755
index 00000000..3b6af514
--- /dev/null
+++ b/utils/get_maintainer.py
@@ -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
