Patchwork D2904: templatefuncs: add mailmap template function

login
register
mail settings
Submitter phabricator
Date March 27, 2018, 4:04 p.m.
Message ID <731c026a3df2618c366337bd86064aeb@localhost.localdomain>
Download mbox | patch
Permalink /patch/29911/
State Not Applicable
Headers show

Comments

phabricator - March 27, 2018, 4:04 p.m.
sheehan updated this revision to Diff 7345.
sheehan marked 5 inline comments as done.

REPOSITORY
  rHG Mercurial

CHANGES SINCE LAST UPDATE
  https://phab.mercurial-scm.org/D2904?vs=7163&id=7345

REVISION DETAIL
  https://phab.mercurial-scm.org/D2904

AFFECTED FILES
  mercurial/templatefuncs.py
  mercurial/utils/stringutil.py
  tests/test-mailmap.t

CHANGE DETAILS




To: sheehan, #hg-reviewers, yuja
Cc: yuja, mercurial-devel

Patch

diff --git a/tests/test-mailmap.t b/tests/test-mailmap.t
new file mode 100644
--- /dev/null
+++ b/tests/test-mailmap.t
@@ -0,0 +1,67 @@ 
+Create a repo and add some commits
+
+  $ hg init mm
+  $ cd mm
+  $ echo "Test content" > testfile1
+  $ hg add testfile1
+  $ hg commit -m "First commit" -u "Proper <commit@m.c>"
+  $ echo "Test content 2" > testfile2
+  $ hg add testfile2
+  $ hg commit -m "Second commit" -u "Commit Name 2 <commit2@m.c>"
+  $ echo "Test content 3" > testfile3
+  $ hg add testfile3
+  $ hg commit -m "Third commit" -u "Commit Name 3 <commit3@m.c>"
+  $ echo "Test content 4" > testfile4
+  $ hg add testfile4
+  $ hg commit -m "Fourth commit" -u "Commit Name 4 <commit4@m.c>"
+
+Add a .mailmap file with each possible entry type plus comments
+  $ cat > .mailmap << EOF
+  > # Comment shouldn't break anything
+  > <proper@m.c> <commit@m.c> # Should update email only
+  > Proper Name 2 <commit2@m.c> # Should update name only
+  > Proper Name 3 <proper@m.c> <commit3@m.c> # Should update name, email due to email
+  > Proper Name 4 <proper@m.c> Commit Name 4 <commit4@m.c> # Should update name, email due to name, email
+  > EOF
+  $ hg add .mailmap
+  $ hg commit -m "Add mailmap file" -u "Testuser <test123@m.c>"
+
+Output of commits should be normal without filter
+  $ hg log -T "{author}\n" -r "all()"
+  Proper <commit@m.c>
+  Commit Name 2 <commit2@m.c>
+  Commit Name 3 <commit3@m.c>
+  Commit Name 4 <commit4@m.c>
+  Testuser <test123@m.c>
+
+Output of commits with filter shows their mailmap values
+  $ hg log -T "{mailmap(author)}\n" -r "all()"
+  Proper <proper@m.c>
+  Proper Name 2 <commit2@m.c>
+  Proper Name 3 <proper@m.c>
+  Proper Name 4 <proper@m.c>
+  Testuser <test123@m.c>
+
+Add new mailmap entry for testuser
+  $ cat >> .mailmap << EOF
+  > <newmmentry@m.c> <test123@m.c>
+  > EOF
+
+Output of commits with filter shows their updated mailmap values
+  $ hg log -T "{mailmap(author)}\n" -r "all()"
+  Proper <proper@m.c>
+  Proper Name 2 <commit2@m.c>
+  Proper Name 3 <proper@m.c>
+  Proper Name 4 <proper@m.c>
+  Testuser <newmmentry@m.c>
+
+A commit with improperly formatted user field should not break the filter
+  $ echo "some more test content" > testfile1
+  $ hg commit -m "Commit with improper user field" -u "Improper user"
+  $ hg log -T "{mailmap(author)}\n" -r "all()"
+  Proper <proper@m.c>
+  Proper Name 2 <commit2@m.c>
+  Proper Name 3 <proper@m.c>
+  Proper Name 4 <proper@m.c>
+  Testuser <newmmentry@m.c>
+  Improper user
diff --git a/mercurial/utils/stringutil.py b/mercurial/utils/stringutil.py
--- a/mercurial/utils/stringutil.py
+++ b/mercurial/utils/stringutil.py
@@ -14,6 +14,7 @@ 
 import textwrap
 
 from ..i18n import _
+from ..thirdparty import attr
 
 from .. import (
     encoding,
@@ -312,6 +313,7 @@ 
 def person(author):
     """Any text. Returns the name before an email address,
     interpreting it as per RFC 5322.
+
     >>> person(b'foo@bar')
     'foo'
     >>> person(b'Foo Bar <foo@bar>')
@@ -334,3 +336,129 @@ 
         return author[:f].strip(' "').replace('\\"', '"')
     f = author.find('@')
     return author[:f].replace('.', ' ')
+
+@attr.s(hash=True)
+class mailmapping(object):
+    '''Represents a username/email key or value in
+    a mailmap file'''
+    email = attr.ib()
+    name = attr.ib(default=None)
+
+def parsemailmap(mailmapcontent):
+    """Parses data in the .mailmap format
+
+    >>> mmdata = "\\n".join([
+    ... '# Comment',
+    ... 'Name <commit1@email.xx>',
+    ... '<name@email.xx> <commit2@email.xx>',
+    ... 'Name <proper@email.xx> <commit3@email.xx>',
+    ... 'Name <proper@email.xx> Commit <commit4@email.xx>',
+    ... ])
+    >>> mm = parsemailmap(mmdata)
+    >>> for key in sorted(mm.keys()):
+    ...     print(key)
+    mailmapping(email='commit1@email.xx', name=None)
+    mailmapping(email='commit2@email.xx', name=None)
+    mailmapping(email='commit3@email.xx', name=None)
+    mailmapping(email='commit4@email.xx', name='Commit')
+    >>> for val in sorted(mm.values()):
+    ...     print(val)
+    mailmapping(email='commit1@email.xx', name='Name')
+    mailmapping(email='name@email.xx', name=None)
+    mailmapping(email='proper@email.xx', name='Name')
+    mailmapping(email='proper@email.xx', name='Name')
+    """
+    mailmap = {}
+    for line in mailmapcontent.splitlines():
+
+        # Don't bother checking the line if it is a comment or
+        # is an improperly formed author field
+        if line.lstrip().startswith('#') or any(c not in line for c in '<>@'):
+            continue
+
+        # name, email hold the parsed emails and names for each line
+        # name_builder holds the words in a persons name
+        name, email = [], []
+        namebuilder = []
+
+        for element in line.split():
+            if element.startswith('#'):
+                # If we reach a comment in the mailmap file, move on
+                break
+
+            elif element.startswith('<') and element.endswith('>'):
+                # We have found an email.
+                # Parse it, and finalize any names from earlier
+                email.append(element[1:-1])  # Slice off the "<>"
+
+                if namebuilder:
+                    name.append(' '.join(namebuilder))
+                    namebuilder = []
+
+                # Break if we have found a second email, any other
+                # data does not fit the spec for .mailmap
+                if len(email) > 1:
+                    break
+
+            else:
+                # We have found another word in the committers name
+                namebuilder.append(element)
+
+        mailmapkey = mailmapping(
+            email=email[-1],
+            name=name[-1] if len(name) == 2 else None,
+        )
+
+        mailmap[mailmapkey] = mailmapping(
+            email=email[0],
+            name=name[0] if name else None,
+        )
+
+    return mailmap
+
+def mapname(mailmap, author):
+    """Returns the author field according to the mailmap cache, or
+    the original author field.
+
+    >>> mmdata = "\\n".join([
+    ...     '# Comment',
+    ...     'Name <commit1@email.xx>',
+    ...     '<name@email.xx> <commit2@email.xx>',
+    ...     'Name <proper@email.xx> <commit3@email.xx>',
+    ...     'Name <proper@email.xx> Commit <commit4@email.xx>',
+    ... ])
+    >>> m = parsemailmap(mmdata)
+    >>> mapname(m, 'Commit <commit1@email.xx>')
+    'Name <commit1@email.xx>'
+    >>> mapname(m, 'Name <commit2@email.xx>')
+    'Name <name@email.xx>'
+    >>> mapname(m, 'Commit <commit3@email.xx>')
+    'Name <proper@email.xx>'
+    >>> mapname(m, 'Commit <commit4@email.xx>')
+    'Name <proper@email.xx>'
+    >>> mapname(m, 'Unknown Name <unknown@email.com>')
+    'Unknown Name <unknown@email.com>'
+    """
+    # If the author field coming in isn't in the correct format,
+    # just return the original author field
+    if not isauthorwellformed(author):
+        return author
+
+    # Turn the user name into a mailmaptup
+    commit = mailmapping(name=person(author), email=email(author))
+
+    try:
+        # Try and use both the commit email and name as the key
+        proper = mailmap[commit]
+
+    except KeyError:
+        # If the lookup fails, use just the email as the key instead
+        # We call this commit2 as not to erase original commit fields
+        commit2 = mailmapping(email=commit.email)
+        proper = mailmap.get(commit2, mailmapping(None, None))
+
+    # Return the author field with proper values filled in
+    return '%s <%s>' % (
+        proper.name if proper.name else commit.name,
+        proper.email if proper.email else commit.email,
+    )
diff --git a/mercurial/templatefuncs.py b/mercurial/templatefuncs.py
--- a/mercurial/templatefuncs.py
+++ b/mercurial/templatefuncs.py
@@ -26,7 +26,10 @@ 
     templateutil,
     util,
 )
-from .utils import dateutil
+from .utils import (
+    dateutil,
+    stringutil,
+)
 
 evalrawexp = templateutil.evalrawexp
 evalfuncarg = templateutil.evalfuncarg
@@ -167,6 +170,33 @@ 
         return node
     return templatefilters.short(node)
 
+@templatefunc('mailmap(author)')
+def mailmap(context, mapping, args):
+    """Return the author, updated according to the value
+    set in the mailmap"""
+    if len(args) != 1:
+        raise error.ParseError(_("mailmap expects one argument"))
+
+    author = evalfuncarg(context, mapping, args[0])
+
+    try:
+        cache = context.resource(mapping, 'cache')
+        repo = context.resource(mapping, 'repo')
+
+        if not repo.wvfs.exists('.mailmap'):
+            return author
+
+        data = repo.wvfs.tryread('.mailmap')
+
+        if 'mailmap' not in cache:
+            cache['mailmap'] = stringutil.parsemailmap(data)
+
+    except (error.ManifestLookupError, IOError):
+        # Return the plain author if no mailmap file is found
+        return author
+
+    return stringutil.mapname(cache['mailmap'], author) or author
+
 @templatefunc('pad(text, width[, fillchar=\' \'[, left=False]])',
               argspec='text width fillchar left')
 def pad(context, mapping, args):