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 @@ -166,6 +169,24 @@ 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 file""" + if len(args) != 1: + raise error.ParseError(_("mailmap expects one argument")) + + author = evalfuncarg(context, mapping, args[0]) + + cache = context.resource(mapping, 'cache') + repo = context.resource(mapping, 'repo') + + if 'mailmap' not in cache: + data = repo.wvfs.tryread('.mailmap') + cache['mailmap'] = stringutil.parsemailmap(data) + + 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): 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, @@ -158,6 +159,136 @@ 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 = b"\\n".join([ + ... b'# Comment', + ... b'Name ', + ... b' ', + ... b'Name ', + ... b'Name Commit ', + ... ]) + >>> 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 = {} + + if mailmapcontent is None: + return 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 = b"\\n".join([ + ... b'# Comment', + ... b'Name ', + ... b' ', + ... b'Name ', + ... b'Name Commit ', + ... ]) + >>> m = parsemailmap(mmdata) + >>> mapname(m, b'Commit ') + 'Name ' + >>> mapname(m, b'Name ') + 'Name ' + >>> mapname(m, b'Commit ') + 'Name ' + >>> mapname(m, b'Commit ') + 'Name ' + >>> mapname(m, b'Unknown Name ') + 'Unknown Name ' + """ + # If the author field coming in isn't in the correct format, + # or the mailmap is empty just return the original author field + if not isauthorwellformed(author) or not mailmap: + 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, + ) + _correctauthorformat = remod.compile(br'^[^<]+\s\<[^<>]+@[^<>]+\>$') def isauthorwellformed(author): 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 " + $ echo "Test content 2" > testfile2 + $ hg add testfile2 + $ hg commit -m "Second commit" -u "Commit Name 2 " + $ echo "Test content 3" > testfile3 + $ hg add testfile3 + $ hg commit -m "Third commit" -u "Commit Name 3 " + $ echo "Test content 4" > testfile4 + $ hg add testfile4 + $ hg commit -m "Fourth commit" -u "Commit Name 4 " + +Add a .mailmap file with each possible entry type plus comments + $ cat > .mailmap << EOF + > # Comment shouldn't break anything + > # Should update email only + > Proper Name 2 # Should update name only + > Proper Name 3 # Should update name, email due to email + > Proper Name 4 Commit Name 4 # Should update name, email due to name, email + > EOF + $ hg add .mailmap + $ hg commit -m "Add mailmap file" -u "Testuser " + +Output of commits should be normal without filter + $ hg log -T "{author}\n" -r "all()" + Proper + Commit Name 2 + Commit Name 3 + Commit Name 4 + Testuser + +Output of commits with filter shows their mailmap values + $ hg log -T "{mailmap(author)}\n" -r "all()" + Proper + Proper Name 2 + Proper Name 3 + Proper Name 4 + Testuser + +Add new mailmap entry for testuser + $ cat >> .mailmap << EOF + > + > EOF + +Output of commits with filter shows their updated mailmap values + $ hg log -T "{mailmap(author)}\n" -r "all()" + Proper + Proper Name 2 + Proper Name 3 + Proper Name 4 + Testuser + +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 Name 2 + Proper Name 3 + Proper Name 4 + Testuser + Improper user