Source code for repomatic.mailmap

# 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] def remove_header(content: str) -> str: """Return content without the generated-by comment header and blank lines above. Strips the metadata block `sync-mailmap` writes at the top of the file (`generated_header` output: `# Generated by …` and `# Timestamp: …` lines), so a re-run parses only the identity mappings. """ lines = [] still_in_header = True for line in content.splitlines(): if still_in_header: # Still in the header as long as the lines are blank or match the # comment format `generated_header` produces. if not line.strip() or line.startswith(( "# Generated by ", "# Timestamp: ", )): continue still_in_header = False # Past the header: keep every remaining line. lines.append(line) return "\n".join(lines)
[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())) )