# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from boltons.iterutils import unique
from .git_ops import list_contributor_identities
MAILMAP_PATH = Path(".mailmap")
"""Canonical path to the `.mailmap` file in the repository root."""
[docs]
@dataclass(order=True)
class Record:
"""A mailmap identity mapping entry."""
# Mapping is define as the first field so we have natural sorting,
# whatever the value of the pre_comment is.
canonical: str = ""
aliases: set[str] = field(default_factory=set)
pre_comment: str = ""
# Pre-computed lowercase versions for efficient case-insensitive matching.
_canonical_lower: str = field(init=False, repr=False, compare=False, default="")
_aliases_lower: frozenset[str] = field(
init=False, repr=False, compare=False, default_factory=frozenset
)
def __post_init__(self) -> None:
"""Normalize pre-comment and pre-compute lowercase identities."""
if self.pre_comment.strip() == "":
self.pre_comment = ""
# Pre-compute lowercase versions to avoid repeated conversions in find().
self._canonical_lower = self.canonical.lower()
self._aliases_lower = frozenset(a.lower() for a in self.aliases)
def __str__(self) -> str:
"""Render the record with pre-comments first, followed by the identity mapping.
Sort all entries in the mapping without case-sensitivity, but keep the first in
its place as the canonical identity.
"""
lines = []
if self.pre_comment:
lines.append(self.pre_comment)
if self.canonical:
lines.append(
" ".join((self.canonical, *sorted(self.aliases, key=str.casefold)))
)
return "\n".join(lines)
[docs]
class Mailmap:
"""Helpers to manipulate `.mailmap` files.
`.mailmap` [file format is documented on Git website](https://git-scm.com/docs/gitmailmap).
"""
records: list[Record]
def __init__(self) -> None:
"""Initialize the mailmap with an empty list of records."""
self.records = []
[docs]
@staticmethod
def split_identities(mapping: str) -> tuple[str, set[str]]:
"""Split a mapping of identities and normalize them."""
identities = []
for identity in map(str.strip, mapping.split(">")):
# Skip blank strings produced by uneven spaces.
if not identity:
continue
assert identity.count("<") == 1, f"Unexpected email format in {identity!r}"
name, email = identity.split("<", maxsplit=1)
identities.append(f"{name.strip()} <{email}>")
assert len(identities), f"No identities found in {mapping!r}"
identities = list(unique(identities))
return identities[0], set(identities[1:])
[docs]
def parse(self, content: str) -> None:
"""Parse mailmap content and add it to the current list of records.
Each non-empty, non-comment line is considered a mapping entry.
The preceding lines of a mapping entry are kept attached to it as pre-comments,
so the layout will be preserved on rendering, during which records are sorted.
"""
logging.debug(f"Parsing:\n{content}")
pre_lines = []
for line in map(str.strip, content.splitlines()):
# Comment lines are added as-is.
if line.startswith("#") or not line:
pre_lines.append(line)
# Mapping entry, which mark the end of a block, so add it to the list
# mailmap records.
else:
canonical, aliases = self.split_identities(line)
record = Record(
pre_comment="\n".join(pre_lines),
canonical=canonical,
aliases=aliases,
)
logging.debug(record)
pre_lines = []
self.records.append(record)
[docs]
def find(self, identity: str) -> bool:
"""Returns `True` if the provided identity matched any record."""
identity_token = identity.lower()
for record in self.records:
# Identity matching is case insensitive:
# https://git-scm.com/docs/gitmailmap#_syntax
# Use pre-computed lowercase values for O(1) lookup.
if (
identity_token == record._canonical_lower
or identity_token in record._aliases_lower
):
return True
return False
[docs]
@cached_property
def git_contributors(self) -> set[str]:
"""Returns the set of all contributors found in the Git commit history.
No normalization happens: all variations of authors and committers strings
attached to all commits are considered. A failing git invocation exits
the process with git's stderr, keeping the CLI's error output clean.
"""
try:
contributors = list_contributor_identities()
except RuntimeError as exc:
sys.exit(str(exc))
logging.debug(
"Authors and committers found in Git history:\n"
+ "\n".join(sorted(contributors, key=str.casefold))
)
return contributors
[docs]
def update_from_git(self) -> None:
"""Add to internal records all missing contributors found in commit history.
This method will refrain from adding contributors already registered as aliases.
"""
for contributor in self.git_contributors:
if not self.find(contributor):
record = Record(canonical=contributor)
logging.info(f"Add new identity {record}")
self.records.append(record)
else:
logging.debug(f"Ignore existing identity {contributor}")
[docs]
def render(self) -> str:
"""Render internal records in Mailmap format."""
# Extract the pre-comment from the first record, if any, so we can keep it
# attached to the top of the file.
top_comment = self.records[0].pre_comment if self.records else ""
if top_comment:
top_comment += "\n"
# Reset the pre-comment of the first record, so it doesn't get duplicated
# in the output.
self.records[0].pre_comment = ""
return top_comment + "\n".join(
map(str, sorted(self.records, key=lambda r: r.canonical.casefold()))
)