#!/usr/bin/env python3
#
# (C) 2018 by Neels Hofmeyr <neels@hofmeyr.de>
# All rights reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import sys
import subprocess
import argparse
import os
import shlex

doc = '''gits: conveniently manage several git subdirectories.
Instead of doing the 'cd foo; git status; cd ../bar; git status' dance, this
helps to save your time with: status, fetch, rebase, ...
'''


def error(*msgs):
    sys.stderr.write(''.join(msgs))
    sys.stderr.write('\n')
    exit(1)


def cmd_to_str(cmd):
    return ' '.join(shlex.quote(c) for c in cmd)


def git(git_dir, *args, may_fail=False, section_marker=False, show_cmd=True):
    sys.stdout.flush()
    sys.stderr.flush()

    if section_marker:
        print('\n===== %s =====' % git_dir)
        sys.stdout.flush()

    cmd = ['git', '-C', git_dir] + list(args)
    if show_cmd:
        print('+ %s' % cmd_to_str(cmd))
        sys.stdout.flush()

    rc = subprocess.call(cmd)
    if rc and not may_fail:
        error('git returned error! command: git -C %r %s' %
              (git_dir, ' '.join(repr(arg) for arg in args)))


def git_output(git_dir, *args):
    return subprocess.check_output(['git', '-C', git_dir, ] + list(args), stderr=subprocess.STDOUT).decode('utf-8')


def git_bool(git_dir, *args):
    try:
        subprocess.check_output(['git', '-C', git_dir, ] + list(args))
        return True
    except subprocess.CalledProcessError as e:
        return False

def safe_branch_name(branch):
    if '/' in branch:
        return branch
    return 'refs/heads/' + branch

def git_branches(git_dir, obj='refs/heads'):
    ret = git_output(git_dir, 'for-each-ref', obj, '--format', '%(refname:short)')
    return ret.splitlines()


def git_branch_current(git_dir):
    ret = git_output(git_dir, 'rev-parse', '--abbrev-ref', 'HEAD').rstrip()
    if ret == 'HEAD':
        return None
    return ret


def git_branch_upstream(git_dir, branch_name='HEAD'):
    '''Return an upstream branch name, or an None if there is none.'''
    try:
        return git_output(git_dir, 'rev-parse', '--abbrev-ref', '%s@{u}' % branch_name).rstrip()
    except subprocess.CalledProcessError:
        return None


def git_has_modifications(git_dir):
    return not git_bool(git_dir, 'diff', '--quiet', 'HEAD')


def git_can_fast_forward(git_dir, branch, branch_upstream):
    return git_bool(git_dir, 'merge-base', '--is-ancestor', branch, branch_upstream)


class AheadBehind:
    ''' Count revisions ahead/behind of the remote branch.
        returns: (ahead, behind) (e.g. (0, 5)) '''
    def __init__(s, git_dir, local, remote):
        s.git_dir = git_dir
        s.local = local
        s.remote = remote
        s.can_ff = False

        if not remote:
            s.ahead = 0
            s.behind = 0
        else:
            behind_str = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (safe_branch_name(local), remote))
            ahead_str = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (remote, safe_branch_name(local)))
            s.ahead = int(ahead_str.rstrip())
            s.behind = int(behind_str.rstrip())
            s.can_ff = s.behind and git_can_fast_forward(git_dir, local, remote)



    def is_diverged(s):
        return s.ahead and s.behind

    def is_behind(s):
        return (not s.ahead) and s.behind

    def is_ahead(s):
        return s.ahead and not s.behind

    def is_sync(s):
        return s.ahead == 0 and s.behind == 0

    def ff(s):
        print('fast-forwarding %s to %s...' % (s.local, s.remote))
        if git_branch_current(s.git_dir) != s.local:
            git(s.git_dir, 'checkout', s.local)
        git(s.git_dir, 'merge', s.remote)

    def __str__(s):
        # Just the branch
        if not s.ahead and not s.behind:
            return s.local

        # Suffix with ahead/behind
        ret = s.local + '['
        if s.ahead:
            ret += '+' + str(s.ahead)
            if s.behind:
                ret += '|'
        if s.behind:
            ret += '-' + str(s.behind)
        ret += ']'
        return ret


def git_branch_summary(git_dir):
    '''return a list of strings: [git_dir, branch-info0, branch-info1,...]
    infos are are arbitrary strings like "master[-1]"'''

    interesting_branch_names = ('master',)

    strs = [git_dir, ]
    if git_has_modifications(git_dir):
        strs.append('MODS')

    branch_current = git_branch_current(git_dir)
    for branch in git_branches(git_dir):
        is_current = (branch == branch_current)
        if not is_current and branch not in interesting_branch_names:
            continue

        ab = AheadBehind(git_dir, branch, git_branch_upstream(git_dir, branch))

        if not ab.ahead and not ab.behind and not is_current:
            # skip branches that are "not interesting"
            continue

        # Branch with ahead/behind upstream info ("master[+1|-5]")
        strs.append(str(ab))
    return strs


def format_summaries(summaries, sep0=' ', sep1=' '):
    first_col = max([len(row[0]) for row in summaries])
    first_col_fmt = '%' + str(first_col) + 's'

    lines = []
    for row in summaries:
        lines.append('%s%s%s' % (first_col_fmt %
                                 row[0], sep0, sep1.join(row[1:])))

    return '\n'.join(lines)


def git_dirs():
    dirs = []
    for sub in os.listdir():
        git_path = os.path.join(sub, '.git')
        if not os.path.isdir(git_path):
            continue
        dirs.append(sub)

    if not dirs:
        error('No subdirectories found that are git clones')

    return list(sorted(dirs))


def print_status():
    infos = [git_branch_summary(git_dir) for git_dir in git_dirs()]
    print(format_summaries(infos))


def cmd_do(argv):
    for git_dir in git_dirs():
        git(git_dir, *argv, may_fail=True, section_marker=True)


def cmd_sh(cmd):
    if not cmd:
        error('which command do you want to run?')
    for git_dir in git_dirs():
        print('\n===== %s =====' % git_dir)
        print('+ %s' % cmd_to_str(cmd))
        sys.stdout.flush()
        subprocess.call(cmd, cwd=git_dir)
        sys.stdout.flush()
        sys.stderr.flush()


class SkipThisRepo(Exception):
    pass


def ask(git_dir, *question, valid_answers=('*',)):
    while True:
        print('\n' + '\n  '.join(question))
        print('  ' + '\n  '.join((
            's  skip this repo',
            't  show in tig',
            'g  show in gitk',
        )))

        answer = sys.stdin.readline().strip()
        if answer == 's':
            raise SkipThisRepo()
        if answer == 't':
            subprocess.call(('tig', '--all'), cwd=git_dir)
            continue
        if answer == 'g':
            subprocess.call(('gitk', '--all'), cwd=git_dir)
            continue

        for v in valid_answers:
            if v == answer:
                return answer
            if v == '*':
                return answer
            if v == '+' and len(answer):
                return answer

def rebase(git_dir):
    orig_branch = git_branch_current(git_dir)
    if orig_branch is None:
        print('Not on a branch: %s' % git_dir)
        raise SkipThisRepo()

    upstream_branch = git_branch_upstream(git_dir, orig_branch)

    print('Checking for rebase of %r onto %r' % (orig_branch, upstream_branch))

    if git_has_modifications(git_dir):
        do_commit = ask(git_dir, 'Local mods.',
                        'c  commit to this branch',
                        '<name>  commit to new branch',
                        '<empty> skip this repo')

        if not do_commit:
            raise SkipThisRepo()

        if do_commit == 'c':
            git(git_dir, 'commit', '-am', 'wip', may_fail=True)
        else:
            git(git_dir, 'checkout', '-b', do_commit)
            git(git_dir, 'commit', '-am', 'wip', may_fail=True)
            git(git_dir, 'checkout', orig_branch)

        if git_has_modifications(git_dir):
            error('There still are local modifications')

    # Missing upstream branch
    if not upstream_branch:
        do_set_upstream = ask(git_dir, 'there is no upstream branch for %r' % orig_branch,
                              '<empty>  skip',
                              'p  create upstream branch (git push --set-upstream orgin %s)' % orig_branch,
                              'm  checkout master',
                              valid_answers=('', 'p', 'm'))

        if do_set_upstream == 'p':
            git(git_dir, 'push', '--set-upstream', 'origin', orig_branch);
            upstream_branch = git_branch_upstream(git_dir, orig_branch)
            if not upstream_branch:
                error('There still is no upstream branch')
        elif do_set_upstream == 'm':
            git(git_dir, 'checkout', 'master')
            return orig_branch
        else:
            print('skipping branch, because there is no upstream: %r' % orig_branch)
            return orig_branch

    while True:
        # bu: branch-to-upstream
        # bm: branch-to-master
        # um: upstream-to-master

        upstream_branch = git_branch_upstream(git_dir, orig_branch)
        um = AheadBehind(git_dir, upstream_branch, 'origin/master')

        bm = AheadBehind(git_dir, orig_branch, 'origin/master')

        if bm.can_ff:
            bm.ff()
            continue

        bu = AheadBehind(git_dir, orig_branch, upstream_branch)

        if bu.can_ff:
            bu.ff()
            continue

        if not bu.is_sync():
            print(str(bu))
        if not bm.is_sync():
            print('to master: ' + str(bm))
        if not um.is_sync():
            print('upstream to master: ' + str(um))

        options = ['----- %s' % git_dir,
                   '<empty>  skip']
        valid_answers = ['']
        all_good = True

        if um.is_diverged():
            all_good = False
            if bu.is_diverged():
                options.append('rum  rebase onto upstream, then onto master')
                valid_answers.append('rum')

        #if bm.is_diverged():
            options.append('rm  rebase onto master: git rebase -i origin/master')
            valid_answers.append('rm')

        if bu.is_diverged():
            all_good = False
            options.append('ru  rebase onto upstream: git rebase -i %s' % upstream_branch)
            valid_answers.append('ru')

        if bu.is_diverged() or bu.is_ahead():
            all_good = False
            options.append('P  push to overwrite upstream: git push -f')
            valid_answers.append('P')

            options.append('RU  reset to upstream: git reset --hard %s' % upstream_branch)
            valid_answers.append('RU')

        if orig_branch == 'master' and (bm.is_ahead() or bm.is_diverged()):
            all_good = False
            options.append('<name>  create new branch')
            valid_answers.append('+')

        if all_good:
            break

        do = ask(git_dir, *options, valid_answers=valid_answers)

        if not do:
            break

        if do == 'rum' or do == 'ru':
            git(git_dir, 'rebase', '-i', upstream_branch)

        if do == 'rum' or do == 'rm':
            git(git_dir, 'rebase', '-i', 'origin/master')

        if do == 'RU':
            git(git_dir, 'reset', '--hard', upstream_branch)

        if do == 'P':
            git(git_dir, 'push', '-f')

        if do not in valid_answers:
            new_branch = do
            # create new branch
            print('''git(git_dir, 'checkout', '-b', new_branch)''')
            #orig_branch = new_branch

    return orig_branch


def cmd_rebase():
    skipped = []
    for git_dir in git_dirs():
        try:
            print('\n\n===== %s =====' % git_dir)
            sys.stdout.flush()

            branch = rebase(git_dir)
            if branch != 'master':
                mm = AheadBehind(git_dir, 'master', 'origin/master')
                if not mm.is_sync():
                    git(git_dir, 'checkout', 'master')
                    rebase(git_dir)
                    git(git_dir, 'checkout', branch)

        except SkipThisRepo:
            print('\nSkipping %r' % git_dir)
            skipped.append(git_dir)

    print('\n\n==========\nrebase done.\n')
    print_status()
    if skipped:
        print('\nskipped: %s' % ' '.join(skipped))


def parse_args():
    parser = argparse.ArgumentParser(description=doc)
    sub = parser.add_subparsers(title='action', dest='action')
    sub.required = True

    # status
    sub.add_parser('status', aliases=['st', 's'],
                   help='show a branch summary and indicate modifications')

    # fetch
    fetch = sub.add_parser('fetch', aliases=['f'],
                           help="run 'git fetch' in each clone (use before rebase)")
    fetch.add_argument('remainder', nargs=argparse.REMAINDER,
                       help='additional arguments to be passed to git fetch')

    # rebase
    sub.add_parser('rebase', aliases=['r', 're'],
                   help='interactively ff-merge master, rebase current branches')

    # sh
    sh = sub.add_parser('sh',
                        help='run shell command in each clone (`gits sh echo hi`)')
    sh.add_argument('remainder', nargs=argparse.REMAINDER,
                    help='command to run in each clone')

    # do
    do = sub.add_parser('do',
                        help='run git command in each clone (`gits do clean -dxf`)')
    do.add_argument('remainder', nargs=argparse.REMAINDER,
                    help='git command to run in each clone')
    return parser.parse_args()


if __name__ == '__main__':
    args = parse_args()
    if args.action in ['status', 's', 'st']:
        print_status()
    elif args.action in ['fetch', 'f']:
        cmd_do(['fetch'] + args.remainder)
    elif args.action in ['rebase', 'r', 're']:
        cmd_rebase()
    elif args.action == 'sh':
        cmd_sh(args.remainder)
    elif args.action == 'do':
        cmd_do(args.remainder)

# vim: shiftwidth=4 expandtab tabstop=4
