/* Distributed under the OSI-approved BSD 3-Clause License.  See accompanying
   file Copyright.txt or https://cmake.org/licensing for details.  */
#include "cmCTestGIT.h"

#include "cmsys/FStream.hxx"
#include "cmsys/Process.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <vector>

#include "cmAlgorithms.h"
#include "cmCTest.h"
#include "cmCTestVC.h"
#include "cmProcessOutput.h"
#include "cmProcessTools.h"
#include "cmSystemTools.h"

static unsigned int
cmCTestGITVersion(unsigned int epic, unsigned int major, unsigned int minor,
                  unsigned int fix)
{
    // 1.6.5.0 maps to 10605000
    return fix + minor * 1000 + major * 100000 + epic * 10000000;
}

cmCTestGIT::cmCTestGIT(cmCTest* ct, std::ostream& log)
: cmCTestGlobalVC(ct, log)
{
    this->PriorRev          = this->Unknown;
    this->CurrentGitVersion = 0;
}

cmCTestGIT::~cmCTestGIT() {}

class cmCTestGIT::OneLineParser : public cmCTestVC::LineParser
{
public:
    OneLineParser(cmCTestGIT* git, const char* prefix, std::string& l)
    : Line1(l)
    {
        this->SetLog(&git->Log, prefix);
    }

private:
    std::string& Line1;
    bool         ProcessLine() override
    {
        // Only the first line is of interest.
        this->Line1 = this->Line;
        return false;
    }
};

std::string
cmCTestGIT::GetWorkingRevision()
{
    // Run plumbing "git rev-list" to get work tree revision.
    const char*   git            = this->CommandLineTool.c_str();
    const char*   git_rev_list[] = { git,    "rev-list", "-n",   "1",
                                   "HEAD", "--",       nullptr };
    std::string   rev;
    OneLineParser out(this, "rl-out> ", rev);
    OutputLogger  err(this->Log, "rl-err> ");
    this->RunChild(git_rev_list, &out, &err);
    return rev;
}

bool
cmCTestGIT::NoteOldRevision()
{
    this->OldRevision = this->GetWorkingRevision();
    cmCTestLog(this->CTest, HANDLER_OUTPUT,
               "   Old revision of repository is: " << this->OldRevision
                                                    << "\n");
    this->PriorRev.Rev = this->OldRevision;
    return true;
}

bool
cmCTestGIT::NoteNewRevision()
{
    this->NewRevision = this->GetWorkingRevision();
    cmCTestLog(this->CTest, HANDLER_OUTPUT,
               "   New revision of repository is: " << this->NewRevision
                                                    << "\n");
    return true;
}

std::string
cmCTestGIT::FindGitDir()
{
    std::string git_dir;

    // Run "git rev-parse --git-dir" to locate the real .git directory.
    const char*   git             = this->CommandLineTool.c_str();
    char const*   git_rev_parse[] = { git, "rev-parse", "--git-dir", nullptr };
    std::string   git_dir_line;
    OneLineParser rev_parse_out(this, "rev-parse-out> ", git_dir_line);
    OutputLogger  rev_parse_err(this->Log, "rev-parse-err> ");
    if(this->RunChild(git_rev_parse, &rev_parse_out, &rev_parse_err, nullptr,
                      cmProcessOutput::UTF8))
    {
        git_dir = git_dir_line;
    }
    if(git_dir.empty())
    {
        git_dir = ".git";
    }

    // Git reports a relative path only when the .git directory is in
    // the current directory.
    if(git_dir[0] == '.')
    {
        git_dir = this->SourceDirectory + "/" + git_dir;
    }
#if defined(_WIN32) && !defined(__CYGWIN__)
    else if(git_dir[0] == '/')
    {
        // Cygwin Git reports a full path that Cygwin understands, but we
        // are a Windows application.  Run "cygpath" to get Windows path.
        std::string cygpath_exe = cmSystemTools::GetFilenamePath(git);
        cygpath_exe += "/cygpath.exe";
        if(cmSystemTools::FileExists(cygpath_exe))
        {
            char const*   cygpath[] = { cygpath_exe.c_str(), "-w",
                                      git_dir.c_str(), 0 };
            OneLineParser cygpath_out(this, "cygpath-out> ", git_dir_line);
            OutputLogger  cygpath_err(this->Log, "cygpath-err> ");
            if(this->RunChild(cygpath, &cygpath_out, &cygpath_err, nullptr,
                              cmProcessOutput::UTF8))
            {
                git_dir = git_dir_line;
            }
        }
    }
#endif
    return git_dir;
}

std::string
cmCTestGIT::FindTopDir()
{
    std::string top_dir = this->SourceDirectory;

    // Run "git rev-parse --show-cdup" to locate the top of the tree.
    const char* git             = this->CommandLineTool.c_str();
    char const* git_rev_parse[] = { git, "rev-parse", "--show-cdup", nullptr };
    std::string cdup;
    OneLineParser rev_parse_out(this, "rev-parse-out> ", cdup);
    OutputLogger  rev_parse_err(this->Log, "rev-parse-err> ");
    if(this->RunChild(git_rev_parse, &rev_parse_out, &rev_parse_err, nullptr,
                      cmProcessOutput::UTF8) &&
       !cdup.empty())
    {
        top_dir += "/";
        top_dir += cdup;
        top_dir = cmSystemTools::CollapseFullPath(top_dir);
    }
    return top_dir;
}

bool
cmCTestGIT::UpdateByFetchAndReset()
{
    const char* git = this->CommandLineTool.c_str();

    // Use "git fetch" to get remote commits.
    std::vector<char const*> git_fetch;
    git_fetch.push_back(git);
    git_fetch.push_back("fetch");

    // Add user-specified update options.
    std::string opts = this->CTest->GetCTestConfiguration("UpdateOptions");
    if(opts.empty())
    {
        opts = this->CTest->GetCTestConfiguration("GITUpdateOptions");
    }
    std::vector<std::string> args = cmSystemTools::ParseArguments(opts.c_str());
    for(std::string const& arg : args)
    {
        git_fetch.push_back(arg.c_str());
    }

    // Sentinel argument.
    git_fetch.push_back(nullptr);

    // Fetch upstream refs.
    OutputLogger fetch_out(this->Log, "fetch-out> ");
    OutputLogger fetch_err(this->Log, "fetch-err> ");
    if(!this->RunUpdateCommand(&git_fetch[0], &fetch_out, &fetch_err))
    {
        return false;
    }

    // Identify the merge head that would be used by "git pull".
    std::string sha1;
    {
        std::string     fetch_head = this->FindGitDir() + "/FETCH_HEAD";
        cmsys::ifstream fin(fetch_head.c_str(),
                            std::ios::in | std::ios::binary);
        if(!fin)
        {
            this->Log << "Unable to open " << fetch_head << "\n";
            return false;
        }
        std::string line;
        while(sha1.empty() && cmSystemTools::GetLineFromStream(fin, line))
        {
            this->Log << "FETCH_HEAD> " << line << "\n";
            if(line.find("\tnot-for-merge\t") == std::string::npos)
            {
                std::string::size_type pos = line.find('\t');
                if(pos != std::string::npos)
                {
                    sha1 = line.substr(0, pos);
                }
            }
        }
        if(sha1.empty())
        {
            this->Log << "FETCH_HEAD has no upstream branch candidate!\n";
            return false;
        }
    }

    // Reset the local branch to point at that tracked from upstream.
    char const* git_reset[] = { git, "reset", "--hard", sha1.c_str(), nullptr };
    OutputLogger reset_out(this->Log, "reset-out> ");
    OutputLogger reset_err(this->Log, "reset-err> ");
    return this->RunChild(&git_reset[0], &reset_out, &reset_err);
}

bool
cmCTestGIT::UpdateByCustom(std::string const& custom)
{
    std::vector<std::string> git_custom_command;
    cmSystemTools::ExpandListArgument(custom, git_custom_command, true);
    std::vector<char const*> git_custom;
    git_custom.reserve(git_custom_command.size() + 1);
    for(std::string const& i : git_custom_command)
    {
        git_custom.push_back(i.c_str());
    }
    git_custom.push_back(nullptr);

    OutputLogger custom_out(this->Log, "custom-out> ");
    OutputLogger custom_err(this->Log, "custom-err> ");
    return this->RunUpdateCommand(&git_custom[0], &custom_out, &custom_err);
}

bool
cmCTestGIT::UpdateInternal()
{
    std::string custom = this->CTest->GetCTestConfiguration("GITUpdateCustom");
    if(!custom.empty())
    {
        return this->UpdateByCustom(custom);
    }
    return this->UpdateByFetchAndReset();
}

bool
cmCTestGIT::UpdateImpl()
{
    if(!this->UpdateInternal())
    {
        return false;
    }

    std::string top_dir        = this->FindTopDir();
    const char* git            = this->CommandLineTool.c_str();
    const char* recursive      = "--recursive";
    const char* sync_recursive = "--recursive";

    // Git < 1.6.5 did not support submodule --recursive
    if(this->GetGitVersion() < cmCTestGITVersion(1, 6, 5, 0))
    {
        recursive = nullptr;
        // No need to require >= 1.6.5 if there are no submodules.
        if(cmSystemTools::FileExists(top_dir + "/.gitmodules"))
        {
            this->Log << "Git < 1.6.5 cannot update submodules recursively\n";
        }
    }

    // Git < 1.8.1 did not support sync --recursive
    if(this->GetGitVersion() < cmCTestGITVersion(1, 8, 1, 0))
    {
        sync_recursive = nullptr;
        // No need to require >= 1.8.1 if there are no submodules.
        if(cmSystemTools::FileExists(top_dir + "/.gitmodules"))
        {
            this->Log
                << "Git < 1.8.1 cannot synchronize submodules recursively\n";
        }
    }

    OutputLogger submodule_out(this->Log, "submodule-out> ");
    OutputLogger submodule_err(this->Log, "submodule-err> ");

    bool ret;

    std::string init_submodules =
        this->CTest->GetCTestConfiguration("GITInitSubmodules");
    if(cmSystemTools::IsOn(init_submodules))
    {
        char const* git_submodule_init[] = { git, "submodule", "init",
                                             nullptr };
        ret = this->RunChild(git_submodule_init, &submodule_out, &submodule_err,
                             top_dir.c_str());

        if(!ret)
        {
            return false;
        }
    }

    char const* git_submodule_sync[] = { git, "submodule", "sync",
                                         sync_recursive, nullptr };
    ret = this->RunChild(git_submodule_sync, &submodule_out, &submodule_err,
                         top_dir.c_str());

    if(!ret)
    {
        return false;
    }

    char const* git_submodule[] = { git, "submodule", "update", recursive,
                                    nullptr };
    return this->RunChild(git_submodule, &submodule_out, &submodule_err,
                          top_dir.c_str());
}

unsigned int
cmCTestGIT::GetGitVersion()
{
    if(!this->CurrentGitVersion)
    {
        const char*   git           = this->CommandLineTool.c_str();
        char const*   git_version[] = { git, "--version", nullptr };
        std::string   version;
        OneLineParser version_out(this, "version-out> ", version);
        OutputLogger  version_err(this->Log, "version-err> ");
        unsigned int  v[4] = { 0, 0, 0, 0 };
        if(this->RunChild(git_version, &version_out, &version_err) &&
           sscanf(version.c_str(), "git version %u.%u.%u.%u", &v[0], &v[1],
                  &v[2], &v[3]) >= 3)
        {
            this->CurrentGitVersion = cmCTestGITVersion(v[0], v[1], v[2], v[3]);
        }
    }
    return this->CurrentGitVersion;
}

/* Diff format:

   :src-mode dst-mode src-sha1 dst-sha1 status\0
   src-path\0
   [dst-path\0]

   The format is repeated for every file changed.  The [dst-path\0]
   line appears only for lines with status 'C' or 'R'.  See 'git help
   diff-tree' for details.
*/
class cmCTestGIT::DiffParser : public cmCTestVC::LineParser
{
public:
    DiffParser(cmCTestGIT* git, const char* prefix)
    : LineParser('\0', false)
    , GIT(git)
    , DiffField(DiffFieldNone)
    {
        this->SetLog(&git->Log, prefix);
    }

    typedef cmCTestGIT::Change Change;
    std::vector<Change>        Changes;

protected:
    cmCTestGIT* GIT;
    enum DiffFieldType
    {
        DiffFieldNone,
        DiffFieldChange,
        DiffFieldSrc,
        DiffFieldDst
    };
    DiffFieldType DiffField;
    Change        CurChange;

    void DiffReset()
    {
        this->DiffField = DiffFieldNone;
        this->Changes.clear();
    }

    bool ProcessLine() override
    {
        if(this->Line[0] == ':')
        {
            this->DiffField = DiffFieldChange;
            this->CurChange = Change();
        }
        if(this->DiffField == DiffFieldChange)
        {
            // :src-mode dst-mode src-sha1 dst-sha1 status
            if(this->Line[0] != ':')
            {
                this->DiffField = DiffFieldNone;
                return true;
            }
            const char* src_mode_first = this->Line.c_str() + 1;
            const char* src_mode_last  = this->ConsumeField(src_mode_first);
            const char* dst_mode_first = this->ConsumeSpace(src_mode_last);
            const char* dst_mode_last  = this->ConsumeField(dst_mode_first);
            const char* src_sha1_first = this->ConsumeSpace(dst_mode_last);
            const char* src_sha1_last  = this->ConsumeField(src_sha1_first);
            const char* dst_sha1_first = this->ConsumeSpace(src_sha1_last);
            const char* dst_sha1_last  = this->ConsumeField(dst_sha1_first);
            const char* status_first   = this->ConsumeSpace(dst_sha1_last);
            const char* status_last    = this->ConsumeField(status_first);
            if(status_first != status_last)
            {
                this->CurChange.Action = *status_first;
                this->DiffField        = DiffFieldSrc;
            } else
            {
                this->DiffField = DiffFieldNone;
            }
        } else if(this->DiffField == DiffFieldSrc)
        {
            // src-path
            if(this->CurChange.Action == 'C')
            {
                // Convert copy to addition of destination.
                this->CurChange.Action = 'A';
                this->DiffField        = DiffFieldDst;
            } else if(this->CurChange.Action == 'R')
            {
                // Convert rename to deletion of source and addition of
                // destination.
                this->CurChange.Action = 'D';
                this->CurChange.Path   = this->Line;
                this->Changes.push_back(this->CurChange);

                this->CurChange = Change('A');
                this->DiffField = DiffFieldDst;
            } else
            {
                this->CurChange.Path = this->Line;
                this->Changes.push_back(this->CurChange);
                this->DiffField = this->DiffFieldNone;
            }
        } else if(this->DiffField == DiffFieldDst)
        {
            // dst-path
            this->CurChange.Path = this->Line;
            this->Changes.push_back(this->CurChange);
            this->DiffField = this->DiffFieldNone;
        }
        return true;
    }

    const char* ConsumeSpace(const char* c)
    {
        while(*c && isspace(*c))
        {
            ++c;
        }
        return c;
    }
    const char* ConsumeField(const char* c)
    {
        while(*c && !isspace(*c))
        {
            ++c;
        }
        return c;
    }
};

/* Commit format:

   commit ...\n
   tree ...\n
   parent ...\n
   author ...\n
   committer ...\n
   \n
       Log message indented by (4) spaces\n
       (even blank lines have the spaces)\n
 [[
   \n
   [Diff format]
 OR
   \0
 ]]

   The header may have more fields.  See 'git help diff-tree'.
*/
class cmCTestGIT::CommitParser : public cmCTestGIT::DiffParser
{
public:
    CommitParser(cmCTestGIT* git, const char* prefix)
    : DiffParser(git, prefix)
    , Section(SectionHeader)
    {
        this->Separator = SectionSep[this->Section];
    }

private:
    typedef cmCTestGIT::Revision Revision;
    enum SectionType
    {
        SectionHeader,
        SectionBody,
        SectionDiff,
        SectionCount
    };
    static char const SectionSep[SectionCount];
    SectionType       Section;
    Revision          Rev;

    struct Person
    {
        std::string   Name;
        std::string   EMail;
        unsigned long Time;
        long          TimeZone;
        Person()
        : Name()
        , EMail()
        , Time(0)
        , TimeZone(0)
        {}
    };

    void ParsePerson(const char* str, Person& person)
    {
        // Person Name <person@domain.com> 1234567890 +0000
        const char* c = str;
        while(*c && isspace(*c))
        {
            ++c;
        }

        const char* name_first = c;
        while(*c && *c != '<')
        {
            ++c;
        }
        const char* name_last = c;
        while(name_last != name_first && isspace(*(name_last - 1)))
        {
            --name_last;
        }
        person.Name.assign(name_first, name_last - name_first);

        const char* email_first = *c ? ++c : c;
        while(*c && *c != '>')
        {
            ++c;
        }
        const char* email_last = *c ? c++ : c;
        person.EMail.assign(email_first, email_last - email_first);

        person.Time     = strtoul(c, const_cast<char**>(&c), 10);
        person.TimeZone = strtol(c, const_cast<char**>(&c), 10);
    }

    bool ProcessLine() override
    {
        if(this->Line.empty())
        {
            if(this->Section == SectionBody && this->LineEnd == '\0')
            {
                // Skip SectionDiff
                this->NextSection();
            }
            this->NextSection();
        } else
        {
            switch(this->Section)
            {
                case SectionHeader:
                    this->DoHeaderLine();
                    break;
                case SectionBody:
                    this->DoBodyLine();
                    break;
                case SectionDiff:
                    this->DiffParser::ProcessLine();
                    break;
                case SectionCount:
                    break;  // never happens
            }
        }
        return true;
    }

    void NextSection()
    {
        this->Section   = SectionType((this->Section + 1) % SectionCount);
        this->Separator = SectionSep[this->Section];
        if(this->Section == SectionHeader)
        {
            this->GIT->DoRevision(this->Rev, this->Changes);
            this->Rev = Revision();
            this->DiffReset();
        }
    }

    void DoHeaderLine()
    {
        // Look for header fields that we need.
        if(cmHasLiteralPrefix(this->Line, "commit "))
        {
            this->Rev.Rev = this->Line.c_str() + 7;
        } else if(cmHasLiteralPrefix(this->Line, "author "))
        {
            Person author;
            this->ParsePerson(this->Line.c_str() + 7, author);
            this->Rev.Author = author.Name;
            this->Rev.EMail  = author.EMail;
            this->Rev.Date   = this->FormatDateTime(author);
        } else if(cmHasLiteralPrefix(this->Line, "committer "))
        {
            Person committer;
            this->ParsePerson(this->Line.c_str() + 10, committer);
            this->Rev.Committer      = committer.Name;
            this->Rev.CommitterEMail = committer.EMail;
            this->Rev.CommitDate     = this->FormatDateTime(committer);
        }
    }

    void DoBodyLine()
    {
        // Commit log lines are indented by 4 spaces.
        if(this->Line.size() >= 4)
        {
            this->Rev.Log += this->Line.substr(4);
        }
        this->Rev.Log += "\n";
    }

    std::string FormatDateTime(Person const& person)
    {
        // Convert the time to a human-readable format that is also easy
        // to machine-parse: "CCYY-MM-DD hh:mm:ss".
        time_t     seconds = static_cast<time_t>(person.Time);
        struct tm* t       = gmtime(&seconds);
        char       dt[1024];
        sprintf(dt, "%04d-%02d-%02d %02d:%02d:%02d", t->tm_year + 1900,
                t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
        std::string out = dt;

        // Add the time-zone field "+zone" or "-zone".
        char tz[32];
        if(person.TimeZone >= 0)
        {
            sprintf(tz, " +%04ld", person.TimeZone);
        } else
        {
            sprintf(tz, " -%04ld", -person.TimeZone);
        }
        out += tz;
        return out;
    }
};

char const cmCTestGIT::CommitParser::SectionSep[SectionCount] = { '\n', '\n',
                                                                  '\0' };

bool
cmCTestGIT::LoadRevisions()
{
    // Use 'git rev-list ... | git diff-tree ...' to get revisions.
    std::string range           = this->OldRevision + ".." + this->NewRevision;
    const char* git             = this->CommandLineTool.c_str();
    const char* git_rev_list[]  = { git,           "rev-list", "--reverse",
                                   range.c_str(), "--",       nullptr };
    const char* git_diff_tree[] = {
        git,  "diff-tree",    "--stdin",          "--always", "-z",
        "-r", "--pretty=raw", "--encoding=utf-8", nullptr
    };
    this->Log << this->ComputeCommandLine(git_rev_list) << " | "
              << this->ComputeCommandLine(git_diff_tree) << "\n";

    cmsysProcess* cp = cmsysProcess_New();
    cmsysProcess_AddCommand(cp, git_rev_list);
    cmsysProcess_AddCommand(cp, git_diff_tree);
    cmsysProcess_SetWorkingDirectory(cp, this->SourceDirectory.c_str());

    CommitParser out(this, "dt-out> ");
    OutputLogger err(this->Log, "dt-err> ");
    this->RunProcess(cp, &out, &err, cmProcessOutput::UTF8);

    // Send one extra zero-byte to terminate the last record.
    out.Process("", 1);

    cmsysProcess_Delete(cp);
    return true;
}

bool
cmCTestGIT::LoadModifications()
{
    const char* git = this->CommandLineTool.c_str();

    // Use 'git update-index' to refresh the index w.r.t. the work tree.
    const char*  git_update_index[] = { git, "update-index", "--refresh",
                                       nullptr };
    OutputLogger ui_out(this->Log, "ui-out> ");
    OutputLogger ui_err(this->Log, "ui-err> ");
    this->RunChild(git_update_index, &ui_out, &ui_err, nullptr,
                   cmProcessOutput::UTF8);

    // Use 'git diff-index' to get modified files.
    const char*  git_diff_index[] = { git,    "diff-index", "-z",
                                     "HEAD", "--",         nullptr };
    DiffParser   out(this, "di-out> ");
    OutputLogger err(this->Log, "di-err> ");
    this->RunChild(git_diff_index, &out, &err, nullptr, cmProcessOutput::UTF8);

    for(Change const& c : out.Changes)
    {
        this->DoModification(PathModified, c.Path);
    }
    return true;
}
