#!/usr/bin/env python
# 
# @file   make_changelog.py
# @brief  Generates Imprudence's ChangeLog.txt from Git commit messages.
#
# Copyright (c) 2010, Jacek Antonelli
#
# The source code in this file is provided to you under the terms of
# the GNU General Public License, version 2.0 ("GPL"). Terms of the
# GPL can be found in doc/GPL-license.txt in this distribution, or
# online at http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
#
# By copying, modifying or distributing this software, you acknowledge
# that you have read and understood your obligations described above,
# and agree to abide by those obligations.
#
# ALL SOURCE CODE IS PROVIDED "AS IS." THE AUTHOR MAKES NO
# WARRANTIES, EXPRESS, IMPLIED OR OTHERWISE, REGARDING ITS ACCURACY,
# COMPLETENESS OR PERFORMANCE.
# 


# This script generates ChangeLog.txt (in the the imprudence root
# directory) from the commit messages in the git repository. It parses
# the output from 'git log', then prints the log entries in a nicely
# formatted way, omitting unimportant or unwanted commits.
# 
# A commit will be omitted if any of the following are true:
# 
#   * It is listed in SPECIAL_COMMITS below with "-".
#   * It has "@nochangelog" in its commit message (but not
#     "@forcechangelog" or "@shortchangelog").
#   * It is a merge commit (i.e. has multiple parent commits).
#   * It is a plain SL source commit ("Second Life viewer sources ...").
# 
# But even if the above are true, it WILL be included if any of the
# following are true:
# 
#   * It is listed in SPECIAL_COMMITS below with "+" or "S".
#   * It has "@forcechangelog" or "@shortchangelog" in its commit
#     message (but not "@nochangelog").
# 
# By default, the ChangeLog will list the files that were modified,
# added, or deleted for each commit. But, that list will be omitted
# (or summarized) if any of the following are true:
# 
#   * More than 10 files were changed.
#   * It is listed in SPECIAL_COMMITS below with "S".
#   * It has "@shortchangelog" in its commit message.
# 


import re, os, sys, subprocess


SCRIPT_DIR = os.path.abspath( os.path.dirname( sys.argv[0] ) )
ROOT_DIR   = os.path.normpath( os.path.join(SCRIPT_DIR, "..", "..") )
CHANGELOG  = os.path.join(ROOT_DIR, "ChangeLog.txt")


# Commit IDs that should be treated specially.
#
# IDs with "+" will always be included, even if they would have been
# omitted for some other reason (such as being a merge commit).
#
# IDs with "-" will be omitted (equivalent to @nochangelog)
#
# IDs with "S" will always be included (like "+"), but will hide or
# summarize the list of changed files (equivalent to @shortchangelog)

SPECIAL_COMMITS = {

    # Merge commits that ARE worth mentioning.
    "13c27361136819af0d0a0f72eeb807f56829337f" : "+",
    "499afbab7be4c4136eea0e1897319c3ac4e9799d" : "+",
    "4bbee8774d743fb4787fc2435a20d89539011fa4" : "+",
    "6a5aab98892df74f60743f5b789959c9593d6647" : "+",
    "6e1e243da8c06ddd6847576c55e134d72ef42491" : "+",
    "87c760f959788e3ec9dc06cbd2207d0242b6a4c9" : "+",
    "8f50d81693ff9463ae49c36935977a2b70e6bf45" : "+",
    "94a57bea1504f2f7024264b15c3ed532d49d3ab0" : "+",
    "96aaf4408601768d1d277bd63e6a1c91295c4c5a" : "+",
    "9a4f5702473e7540cde1bbff2d7189d9ed71fd86" : "+",

    # Mention the first SL version Imprudence is based on (1.20.15)
    "31ba61e675d42675c6c6cd1077a93e0c5e055114" : "S",

    # Project early setup. Not worth mentioning.
    "6f0d1dc6b922f1b103a8933a03c4ed5e10b290ef" : "-",
    "993bca391adf825dcf139d516c17dfdca0832bc8" : "-",
    "e8ca04117d66356e8fe514d617d1da2d9b49a927" : "-",
    "ec8b17013071896e7228c7d0032d3bfc44697c3a" : "-",
    "f258e5d9af1087cab2be36a8c143815300187d62" : "-",
    "f37093d4dda55fd77bc9228c9fb359d46d4f1715" : "-",

    # Whitespace and line ending changes. Not worth mentioning.
    "285dcc2660ef0ed31bfbdc4f0a5cc53f40e90e36" : "-",
    "44dc53ef5d8f770412c2f7675b9ebfdeb3c2b698" : "-",
    "451aad0c993856f380d976de3d7fe343ad5f9811" : "-",
    "45beb0b1d8a16522dfc33894f4b69ee9a4b33efc" : "-",
    "567586af40a1683a3c89863c9dbedd2b3cc90897" : "-",
    "5bd80f31b2a6d06f6b84e444ed84b744bfebc311" : "-",
    "65272bae7013a785cb4e1dccb810e58e9f7fdfda" : "-",
    "8186bd3db550d2a5cafd840679e8b13ff10a82b5" : "-",
    "87494eab8a1221ccb35e91c14d242e4cfaab28c8" : "-",
    "93eb46dcb67e35e99e3f68b4cb23ee9bf2fbb2d8" : "-",
    "a86dba93c641056fc08ff9dc4bd173bc4b56036f" : "-",
    "b055fc86474cce8854cadadccec61f10dc6ef003" : "-",
    "c05810ec6ce1cb1fb40915b2b16f44c2600c6483" : "-",
    "cf4ee03e4415d599c10729d920a1c085aed411ed" : "-",
    "e20f3bb7c3310deca86cfc66af0a086261930bcf" : "-",

    # ChangeLog edits. Not worth mentioning.
    "0ee6701062d0506a725cd4ba9cd533ec4c46eb68" : "-",
    "197b6a954d37e46af91a474de1565b43e24e8c60" : "-",
    "2027db4808f36bcc230686f31dea3810d594701e" : "-",
    "2583bbaf227abd654cd87760bf6c646909b38229" : "-",
    "388810f1cb7505c7888f6aa77306554b99430202" : "-",
    "6b270891b1b64e26a5c07e9239b925527e7be4c2" : "-",
    "6e99cd9128a04ce8b5daa9b1c5c17c7b86e8a058" : "-",
    "78cff2cb53235979d452b877192523a030686f89" : "-",
    "818367f405a550e95c265d26c7011a5a1f892ec7" : "-",
    "87c7622724089f7a06b6559c9e1103e2f7bac2ef" : "-",
    "925975639a2438bc9bdf43a0ceaab1c4555052b0" : "-",
    "96aaf4408601768d1d277bd63e6a1c91295c4c5a" : "-",
    "9f89d959d4596a5e4918806efc2e5dd044cabc62" : "-",
    "a03abda0f0671ce69b9579b9fcf3d9d499080a5d" : "-",
    "b2efe398cd2a506fe47a6143ea54e08b1f4cbbc7" : "-",
    "b31f72f11b75cf0c7965d8170600e1352988dc25" : "-",
    "b33c4cfbbc49bb6c66e7bdd53d3a2245e1c6a2c9" : "-",
    "b5068c60260646572d3dad166e11c943050b8bd6" : "-",
    "cdd95787ae40cd1ea3776d2322ec529a5a232fc7" : "-",
    "d53da842e2f259282fb83c01ea742d80f7d2735e" : "-",
    "d6552c4c9c10a886ea19768e0097b7344809ea02" : "-",
    "ded2589eae6e392d2ff973d882a0182d2af199cf" : "-",
    "ea8040185053bd2807d0cfdf5cd018f08df10ed1" : "-",
    "ebce3d7682489151a6fc48a923c7154dad401219" : "-",
    "ed6a76513af6a7de5963517e19982293d418ddef" : "-",
    "eeb428b559f482b03ac1002a391a5df3e1512e9a" : "-",
    "f0acc222f33936756297a8240bcfc72c2f9ba891" : "-",

    # Release notes edits. Not worth mentioning.
    "08161bf33c3e17ec81c0d66cb7d2fda678ff6a17" : "-",
    "4f1e0a28875a8c54790d07442bfc3875f903b3c2" : "-",
    "60c7bcbed46bbc266beb642a71018b18c213078e" : "-",
    "8a662850a3ddae4485270c59e9f93c95691f5050" : "-",
    "c34a99c0b18d1122d5613c0048e572d70e7ce751" : "-",

    # Duplicates of other commits. Not worth mentioning.
    "23ab8d114f3038b8425613cd0c74adb160d0bf2f" : "-",
    "3a84d8017df08447b14161cee3c24382f8f96013" : "-",
    "6a7a1881a3fde403e9dacd638d10d457c50ab19e" : "-",
    "d345613880689d38fe6418f47dc19ae855849f92" : "-",

    # Miscellaneous insignificant things.
    "16751a56c0c465f90cb14f65483f967acb1220ae" : "-",
    "25919b3c9601918dbddaccb07ef48fc14a546cf1" : "-",
    "25ee74981392a1f1fde299f8ecf243a6b257e3ae" : "-",
    "5be6de589be48be699d8aa46593d0a0d3e88f46a" : "-",
    "686b994f11a5e7ec2c11e40dc3ca8178c47decdf" : "-",
    "9625d9f3253e0aa4ca8a68380c6303f0b8d36dbe" : "-",
    "a4ecbdbba79e49ce5653101c6c71f8b5df7e0fc5" : "-",
    "e563b107a2b58935ef8c3ab2cfdfdbc2d7cdfa2d" : "-",
    "e7c2d187818158c1c27f87056fba82b0e8077263" : "-",

    # Spammy and uninformative.
    "7f090f7bec5264ca9e203c27dfb6b2992bb2bcbd" : "-",
    "844025196a1b9a5944cac292dbc162bdd24ac5af" : "-",

    # Tsk tsk, McCabe has a potty mouth.
    "7624d729930c331494c7391e3fbc596b31309782" : "-",
    "a67f7ec260b20474cee3c2edca3c3f4ea331c815" : "-",

}



class LogEntry:
    "Stores and formats a Git log entry"

    # A horizontal line to put between log entries
    separator = "-" * 79


    def __init__(self, git_text):
        "Creates a LogEntry from a commit output from `git log`"

        self.id          = ""     # commit id
        self.merge       = False  # is a merge commit?
        self.author_name = ""
        self.author_mail = ""
        self.author_date = ""
        self.commit_name = ""     # committer name
        self.commit_mail = ""
        self.commit_date = ""
        self.message     = []     # lines of the message
        self.files       = []     # modified/added/deleted files
        self.special     = ""     # handle this commit in a special way?

        lines = git_text.splitlines()

        # First line is always commit id.
        self.id = lines[0]

        for line in lines[1:]:

            # is it a merge?
            if re.match("Merge: +([0-9a-f]+ *){2,}", line):
                self.merge = True

            # author name
            match = re.match("Author: +([^<]+) <([^>]+)>", line)
            if match:
                self.author_name = match.group(1)
                self.author_mail = match.group(2)

            # author date
            match = re.match("AuthorDate: +(.+)", line)
            if match:
                self.author_date = match.group(1)

            # commit name
            match = re.match("Commit: +([^<]+) <([^>]+)>", line)
            if match:
                self.commit_name = match.group(1)
                self.commit_mail = match.group(2)

            # commit date
            match = re.match("CommitDate: +(.+)", line)
            if match:
                self.commit_date = match.group(1)

            # message (and notes)
            if re.match("[\t ]+", line):
                self.message.append(line[4:])

            # modified/added/deleted files (but ignore ChangeLog.txt)
            if re.match("[MAD][\t ]+", line) and \
                    not re.match("[MAD][\t ]+ChangeLog.txt", line):
                self.files.append(line)

        self.author_name, self.author_mail = \
            self.fix_name_email( self.author_name, self.author_mail )

        self.commit_name, self.commit_mail = \
            self.fix_name_email( self.commit_name, self.commit_mail )

        try:
            self.special = SPECIAL_COMMITS[self.id]
        except KeyError:
            pass

        # If there's no special already, scan the message for @directives.
        if not self.special:
            for line in self.message:
                # Omit commits with @nochangelog in the message
                if("@nochangelog" in line):
                    self.special = "-"
                # Don't show file changes for commits with @shortchangelog
                elif("@shortchangelog" in line):
                    self.special = "S"
                # Always include commits with @forcechangelog
                elif("@forcechangelog" in line):
                    self.special = "+"


    def fix_name_email( self, name, email ):
        """Some commits have a bad name or email address.
           This function returns the proper name and address."""

        if email in ["Adric@.(none)", "hakushakukun@gmail.com"]:
            return ["McCabe Maxsted", "hakushakukun@gmail.com"]

        if email in ["Kakurady@.(none)", "kakurady@gmail.com"]:
            return ["Kakurady (Geneko Nemeth)", "kakurady@gmail.com"]

        # Nothing to fix.
        return [name, email]


    def should_be_included(self):
        """Returns True if the commit should be included in the
        ChangeLog, or False if it should be omitted."""

        # Include commits marked with "+" or "S" in special_commits.txt
        if self.special in ["+", "S"]:
            return True

        # Omit commits marked with "-" in special_commits.txt
        if self.special == "-":
            return False

        # Omit merge commits
        if self.merge:
            return False

        # Omit vanilla SL source commits
        if self.message[0].startswith("Second Life viewer sources"):
            return False

        # Include everything else
        return True


    def format(self):
        "Formats the LogEntry prettily."
        texts=[LogEntry.separator]

        texts.append("""
Date:       %(ad)s                                                (%(id)s)
Author:     %(an)s  <%(ae)s>""" % { "ad" : self.author_date,
                                    "an" : self.author_name,
                                    "ae" : self.author_mail,
                                    "id" : self.id[0:7] })

        if self.commit_name != self.author_name:
            texts.append("Committer:  %(cn)s  <%(ce)s>" % \
                             { "cn" : self.commit_name,
                               "ce" : self.commit_mail })

        texts.append("\n")

        for line in self.message:
            # Remove @...changelog directives
            rxp = re.compile("@(short|no)changelog")
            if rxp.search(line):
                line = rxp.sub("", line)
                # Skip this line if it was empty except for @...changelog
                if not line.strip():
                    continue

            # Skip modified/deleted/new file lines.
            if re.match("[ \t]+(modified|deleted|new file): +.+", line):
                continue

            # Skip all the rest of the lines.
            if re.match("Conflicts:$", line):
                break

            texts.append( (" "*2 + line.replace("\t"," "*4)).rstrip() )

        # Delete all empty lines at the end
        while not texts[-1].strip():
            del texts[-1]

        # Don't list files if it's a short changelog or there are more
        # than ten files to list.
        if self.special == "S" or len(self.files) > 10:
            texts.append("\n  (File changes omitted for brevity.)")
        else:
            if self.files:
                texts.append("\n")
                for line in self.files:
                    texts.append( " "*2 + line.replace("\t"," "*2) )

        texts.append("\n")

        return "\n".join(texts)



def fail( reason, abort=False ):
    """Prints a message that the ChangeLog couldn't be generated, then
    exits the script. If abort is True, exit with status code 1 (to
    indicate that Make/VisualStudio/Xcode/etc. should abort),
    otherwise exit with status code 0."""

    if abort:
        print "Error: Could not generate ChangeLog.txt: " + reason
        exit(1)
    else:
        print "Warning: Could not generate ChangeLog.txt: " + reason
        exit(0)
        


def main():
    commits = sys.argv[1:]
    if not commits:
        commits = ["HEAD"]


    # Set PATH to help find the git executable on Mac OS X.
    if sys.platform == "darwin":
        os.environ["PATH"] += ":/usr/local/bin:/usr/local/git/bin:/sw/bin:/opt/bin:~/bin"


    # Fetch the log entries from git in one big chunk.
    cmd = ["git", "log", "--pretty=fuller", "--name-status",
           "--date=short", "--date-order"] + commits

    try:
        proc = subprocess.Popen( [" ".join(cmd)], 
                                cwd    = ROOT_DIR,
                                stdout = subprocess.PIPE,
                                stderr = subprocess.STDOUT,
                                shell  = True)
    except OSError:
        fail("The 'git' command is not available.")
    
    output = proc.communicate()[0]
    status = proc.returncode


    # If the git command failed, print the reason and exit.
    if status != 0:
        fail(output)

    # Split it up into individual commits.
    logs = re.compile("^commit ", re.MULTILINE).split(output)[1:]


    # Introductory header that goes at the top of the ChangeLog.
    header="""

                                  CHANGELOG
                           for the Imprudence Viewer
                          http://imprudenceviewer.org


     This is a log of revisions to the Imprudence Viewer source code.
     If you are looking for an overview of new features and changes in
     each release, please see RELEASE_NOTES.txt instead.

     This file is automatically generated from the Git commit history.
     Be aware that it is NOT ORDERED BY DATE, but rather by ancestry.
     Some commits have been omitted from this list for brevity.

     File changes are annotated as follows:

       A  =  the file was added
       M  =  the file was modified
       D  =  the file was deleted

     For a full history of the source code, including diffs, please see
     the Git repository.


"""

    output = [header]

    for log in logs:
        entry = LogEntry(log)
        if entry.should_be_included():
            output.append( entry.format() )

    text = "\n".join(output)

    changelog = open(CHANGELOG, "w")
    changelog.write(text)
    changelog.close()



if __name__ == "__main__":
    main()