#!/usr/bin/python3
"""
git-weave - assemble tree sequences into repository histories

Requires git, find, and cpio.

Permission is explicitly granted for the git project to distribute
under the maintainer's choice of license.
"""
# SPDX-FileCopyrightText: (C) Eric S. Raymond <esr@thyrsus.com>
# SPDX-License-Identifier: BSD-2-Clause


# pylint: disable=line-too-long,invalid-name,missing-function-docstring,no-else-return,no-else-raise,raise-missing-from.too-many-locals,too-many-nested-blocks,too-many-branches,too-many-statements,redefined-outer-name,consider-using-f-string,consider-using-with,use-maxsplit-arg

# pylint: disable=multiple-imports
import sys, os, getopt, subprocess, time, tempfile

version = "1.5"

DEBUG_GENERAL = 1
DEBUG_PROGRESS = 2
DEBUG_COMMANDS = 3

binary_encoding = "latin-1"

if str is bytes:  # Python 2

    polystr = str
    polybytes = bytes

else:  # Python 3

    def polystr(o):
        if isinstance(o, str):
            return o
        if isinstance(o, bytes):
            return str(o, encoding=binary_encoding)
        raise ValueError

    def polybytes(o):
        if isinstance(o, bytes):
            return o
        if isinstance(o, str):
            return bytes(o, encoding=binary_encoding)
        raise ValueError


class Fatal(Exception):
    "Unrecoverable error."

    def __init__(self, msg):
        Exception.__init__(self)
        self.msg = msg


class Baton:
    "Ship progress indications to stdout."

    def __init__(self, prompt, endmsg="done", enable=False):
        self.prompt = prompt
        self.endmsg = endmsg
        self.countfmt = None
        self.counter = 0
        if enable:
            self.stream = sys.stdout
        else:
            self.stream = None
        self.count = 0
        self.time = 0

    def __enter__(self):
        if self.stream:
            self.stream.write(self.prompt + "...")
            if os.isatty(self.stream.fileno()):
                self.stream.write(" \010")
            self.stream.flush()
        self.count = 0
        self.time = time.time()
        return self

    def startcounter(self, countfmt, initial=1):
        self.countfmt = countfmt
        self.counter = initial

    def bumpcounter(self):
        if self.stream is None:
            return
        if os.isatty(self.stream.fileno()):
            if self.countfmt:
                update = self.countfmt % self.counter
                self.stream.write(update + ("\010" * len(update)))
                self.stream.flush()
            else:
                self.twirl()
        self.counter = self.counter + 1

    def endcounter(self):
        if self.stream:
            w = len(self.countfmt % self.count)
            self.stream.write((" " * w) + ("\010" * w))
            self.stream.flush()
        self.countfmt = None

    def twirl(self, ch=None):
        "One twirl of the baton."
        if self.stream is None:
            return
        if os.isatty(self.stream.fileno()):
            if ch:
                self.stream.write(ch)
                self.stream.flush()
                return
            else:
                update = "-/|\\"[self.count % 4]
                self.stream.write(update + ("\010" * len(update)))
                self.stream.flush()
        self.count = self.count + 1

    def __exit__(self, extype, value_unused, traceback_unused):
        if extype == KeyboardInterrupt:
            self.endmsg = "interrupted"
        if extype == Fatal:
            self.endmsg = "aborted by error"
        if self.stream:
            self.stream.write(
                "...(%2.2f sec) %s.\n" % (time.time() - self.time, self.endmsg)
            )
        return False


def do_or_die(dcmd, legend=""):
    "Either execute a command or raise a fatal exception."
    if legend:
        legend = " " + legend
    if verbose >= DEBUG_COMMANDS:
        sys.stdout.write("executing '%s'%s\n" % (dcmd, legend))
    try:
        retcode = subprocess.call(dcmd, shell=True)
        if retcode < 0:
            raise Fatal(
                "execution of '%s'%s was terminated by signal %d."
                % (dcmd, legend, -retcode)
            )
        elif retcode != 0:
            raise Fatal("execution of '%s'%s returned %d." % (dcmd, legend, retcode))
    except (OSError, IOError) as e:
        raise Fatal("execution of '%s'%s failed: %s" % (dcmd, legend, e))


def capture_or_die(dcmd, legend=""):
    "Either execute a command and capture its output or die."
    if legend:
        legend = " " + legend
    if verbose >= DEBUG_COMMANDS:
        sys.stdout.write("executing '%s'%s\n" % (dcmd, legend))
    try:
        return polystr(subprocess.check_output(dcmd, shell=True))
    except subprocess.CalledProcessError as e:
        if e.returncode < 0:
            raise Fatal(
                "execution of '%s'%s was terminated by signal %d."
                % (dcmd, legend, -e.returncode)
            )
        elif e.returncode != 0:
            raise Fatal(
                "execution of '%s'%s returned %d." % (dcmd, legend, e.returncode)
            )
        sys.exit(1)


def git_weave(indir, outdir, quiet=False):
    "Weave a tree sequence and associated logfile into a repository"
    myname = capture_or_die("git config --get user.name")
    myemail = capture_or_die("git config --get user.email")
    do_or_die("mkdir %s; git init -q %s" % (outdir, outdir))
    logfile = os.path.join(indir, "log")
    commit_id = {}
    state = 0
    parents = []
    commit = tagname = label = refers_to = head = comment = ""
    committername = authorname = myname
    committeremail = authoremail = myemail
    committerdate = authordate = ""
    directory = None
    commitcount = 1
    linecount = 0
    with Baton("Weaving", enable=not quiet) as baton:
        for line in open(logfile, "rb"):
            line = polystr(line)
            linecount += 1
            if verbose > DEBUG_PROGRESS:
                print("Looking at %d: %s" % (linecount, repr(line)))
            if state == 0:
                if line == "\n":
                    state = 1
                else:
                    if line.startswith("#"):
                        continue
                    try:
                        space = line.index(" ")
                        leader = line[:space]
                        follower = line[space:].strip()
                        if leader == "commit":
                            commit = follower
                        elif leader == "directory":
                            directory = follower
                        elif leader == "tag":
                            tagname = follower
                        elif leader == "branch":
                            head = follower
                        elif leader == "parent":
                            parents.append(follower)
                        elif leader == "label":
                            label = follower
                        elif leader == "refers-to":
                            if tagname:
                                refers_to = follower
                            elif head:
                                do_or_die(
                                    "git update-ref refs/heads/%s %s"
                                    % (head, commit_id[follower])
                                )
                            else:
                                do_or_die(
                                    "git tag %s %s" % (label, commit_id[follower])
                                )
                        elif leader not in ("author", "committer", "tagger"):
                            raise Fatal("unexpected log attribute at %s" % repr(line))
                        elif leader in {"committer", "tagger"}:
                            (committername, committeremail, committerdate) = [
                                x.strip() for x in follower.replace(">", "<").split("<")
                            ]
                            committerdate = committerdate.strip()
                        elif leader == "author":
                            (authorname, authoremail, authordate) = [
                                x.strip() for x in follower.replace(">", "<").split("<")
                            ]
                            authordate = authordate.strip()
                    except ValueError:
                        raise Fatal(
                            "%s:%d: ill-formed log entry" % (logfile, linecount)
                        )
            elif state == 1:
                if line == ".\n":
                    if verbose > DEBUG_PROGRESS:
                        print("Interpretation begins")
                    os.chdir(outdir)
                    if commit:
                        if commitcount > 1:
                            do_or_die("rm -f `git ls-tree -r --name-only HEAD`")
                        if verbose > DEBUG_PROGRESS:
                            print("Copying")
                        os.chdir("%s/%s" % (indir, directory or commitcount))
                        do_or_die("find . -print | cpio -pd --quiet %s" % (outdir,))
                        if committerdate == "" or authordate == "":
                            latest = 0
                            for dirname, _subdirlist, filelist in os.walk("."):
                                for fname in filelist:
                                    mtime = os.path.getmtime(
                                        os.path.join(dirname, fname)
                                    )
                                    if mtime > latest:
                                        latest = mtime
                            if committerdate == "":
                                committerdate = latest
                            if authordate == "":
                                authordate = latest
                        os.chdir(outdir)
                        do_or_die("git add -A")
                        tree_id = capture_or_die("git write-tree").strip()
                        if verbose > DEBUG_PROGRESS:
                            print("Tree ID is %s" % tree_id)
                        (_, commentfile) = tempfile.mkstemp()
                        with open(commentfile, "wb") as cfp:
                            cfp.write(polybytes(comment))
                        command = "git commit-tree %s " % tree_id
                        command += " ".join(["-p " + commit_id[p] for p in parents])
                        command += "<'%s'" % commentfile
                        environment = ""
                        environment += " GIT_AUTHOR_NAME='%s' " % authorname
                        environment += " GIT_AUTHOR_EMAIL='%s' " % authoremail
                        environment += " GIT_AUTHOR_DATE='%s' " % authordate
                        environment += " GIT_COMMITTER_NAME='%s' " % committername
                        environment += " GIT_COMMITTER_EMAIL='%s' " % committeremail
                        environment += " GIT_COMMITTER_DATE='%s' " % committerdate
                        commit_id[commit] = capture_or_die(
                            environment + command
                        ).strip()
                        do_or_die("git update-ref HEAD %s" % commit_id[commit])
                        os.remove(commentfile)
                        commitcount += 1
                        baton.twirl()
                        if maxcommit != 0 and commitcount >= maxcommit:
                            break
                    if tagname and refers_to:
                        environment += " GIT_COMMITTER_NAME='%s' " % committername
                        environment += " GIT_COMMITTER_EMAIL='%s' " % committeremail
                        environment += " GIT_COMMITTER_DATE='%s' " % committerdate
                        command = "git tag -a -m '%s' %s %s" % (
                            comment,
                            tagname,
                            commit_id[refers_to],
                        )
                        do_or_die(environment + command)
                    state = 0
                    baton.twirl()
                    parents = []
                    commit = tagname = label = refers_to = head = comment = ""
                    committername = authorname = myname
                    committeremail = authoremail = myemail
                    committerdate = authordate = ""
                    directory = None
                else:
                    if line.startswith("."):
                        line = line[1:]
                    comment += line


def git_ravel(indir, outdir, quiet=False):
    "Ravel a repository into a tree sequence and associated logfile."
    rawlogfile = os.path.join(outdir, "rawlog")
    with Baton("Raveling", enable=not quiet) as baton:
        do_or_die("rm -fr %s; mkdir %s" % (outdir, outdir))
        baton.twirl()
        do_or_die(
            "cd %s; git log --all --reverse --date-order --format=raw >%s"
            % (indir, rawlogfile)
        )
        baton.twirl()
        commitcount = 1
        commit_map = {}
        os.chdir(indir)
        try:
            for line in open(rawlogfile, "rb"):
                line = polystr(line)
                baton.twirl()
                if line.startswith("commit "):
                    commit = line.split()[1]
                    commit_map[commit] = commitcount
                    do_or_die(
                        "git checkout %s 2>/dev/null; mkdir %s/%d"
                        % (commit, outdir, commitcount)
                    )
                    do_or_die(
                        "git ls-tree -r --full-name --name-only %s | cpio -pd --quiet %s/%d"
                        % (commit, outdir, commitcount)
                    )
                    commitcount += 1

        finally:
            do_or_die(
                "git reset --hard >/dev/null; git checkout master >/dev/null 2>&1"
            )
        cooked = os.path.join(outdir, "log")
        body_latch = False
        line = ""
        try:
            with open(cooked, "wb") as wfp:
                linecount = 0
                for line in open(rawlogfile, "rb"):
                    line = polystr(line)
                    linecount += 1
                    if line[0].isspace():
                        if line.startswith(" " * 4):
                            line = line[4:]
                            if line == "\n":
                                body_latch = False
                            # Old-school byte stuffing.
                            if line.startswith("."):
                                line = "." + line
                    else:
                        space = line.index(" ")
                        leader = line[:space]
                        follower = line[space:].strip()
                        if leader == "tree":
                            continue
                        if leader == "commit" and linecount > 1:
                            wfp.write(polybytes(".\n"))
                        if leader in ("commit", "parent"):
                            line = "%s %s\n" % (leader, commit_map[follower])
                            body_latch = False
                        elif leader not in ("author", "committer"):
                            raise Fatal("unexpected log attribute at %s" % repr(line))
                    if line == "\n":
                        if not body_latch:
                            body_latch = True
                        else:
                            continue
                    wfp.write(polybytes(line))
                wfp.write(polybytes(".\n"))
                # Append information about tags
                for tagname in capture_or_die("git tag -l").split():
                    tagdata = capture_or_die("git show --format=raw %s" % tagname)
                    if tagdata.startswith("commit"):
                        commit = commit_map[tagdata.split("\n")[0].split()[1]]
                        wfp.write(
                            polybytes("label %s\nrefers-to %s\n" % (tagname, commit))
                        )
                    elif tagdata.startswith("tag"):
                        for line in tagdata.split("\n"):
                            if line.startswith("commit"):
                                commit = line.strip().split()[1]
                                break
                        else:
                            raise Fatal("no commit line in annotated tag")
                        wfp.write(
                            polybytes(
                                "tag %s\nrefers-to %s\n" % (tagname, commit_map[commit])
                            )
                        )
                        stanza = capture_or_die("git cat-file -p " + tagname)
                        body_latch = False
                        for line in stanza.split("\n")[:-1]:
                            if not body_latch:
                                if line == "":
                                    body_latch = True
                                    wfp.write(polybytes("\n"))
                                elif line.startswith("tagger"):
                                    wfp.write(polybytes(line + "\n"))
                            else:
                                if line.startswith("."):
                                    line = "." + line
                                wfp.write(polybytes(line + "\n"))
                        wfp.write(polybytes(".\n"))
                # And about heads too
                for line in capture_or_die("git show-ref --head").split("\n"):
                    if line:
                        (hashid, ref) = line.strip().split()
                        if ref.startswith("refs/heads"):
                            ref = os.path.basename(ref)
                            wfp.write(
                                polybytes(
                                    "branch %s\nrefers-to %s\n"
                                    % (ref, commit_map[hashid])
                                )
                            )
        except (ValueError, IndexError, KeyError):
            print("log rewrite failed on %s" % repr(line))
            raise
    os.remove(rawlogfile)


if __name__ == "__main__":
    if sys.hexversion < 0x02060000:
        sys.stderr.write("git-weave: requires Python 2.6 or later.\n")
        sys.exit(1)
    (options, arguments) = getopt.getopt(sys.argv[1:], "m:qv")
    mode = "auto"
    indir = "."
    outdir = None
    quiet = False
    maxcommit = 0
    verbose = 0
    for (opt, val) in options:
        if opt == "-m":
            maxcommit = int(val)
        elif opt == "-q":
            quiet = True
        elif opt == "-v":
            verbose += 1
    if len(arguments) < 2:
        sys.stderr.write(
            "git-weave: input and output directory arguments are required.\n"
        )
        sys.exit(1)
    else:
        (indir, outdir) = arguments[:2]
    if not os.path.exists(indir):
        sys.stderr.write("git-weave: input directory %s must exist.\n" % indir)
        sys.exit(1)
    if os.path.exists(outdir):
        sys.stderr.write("git-weave: output directory %s must not exist.\n" % outdir)
        sys.exit(1)
    if os.path.exists(os.path.join(indir, ".git")):
        mode = "ravel"
    else:
        mode = "weave"
    indir = os.path.abspath(indir)
    outdir = os.path.abspath(outdir)
    if verbose >= DEBUG_PROGRESS:
        sys.stderr.write("git-weave: %s from %s to %s.\n" % (mode, indir, outdir))
    try:
        try:
            here = os.getcwd()
            if mode == "weave":
                git_weave(indir, outdir, quiet=quiet)
            elif mode == "ravel":
                git_ravel(indir, outdir, quiet=quiet)
        finally:
            os.chdir(here)
    except Fatal as err:
        sys.stderr.write("git-weave: " + err.msg + "\n")
        sys.exit(1)
    except KeyboardInterrupt:
        pass

# end
