#! /usr/bin/env python3

################################################################################
""" Git module V2 - now implemented as a wrapper around pygit2 (work in progress)

    Copyright (C) 2023 John Skilleter

    Licence: GPL v3 or later

    Except where stated otherwise, functions in this module:

    * Return the output from the equivalent git command as an array of
      strings.

    * Functions will raise exceptions on error. If the underlying git command
      returns an error, a git.GitError() exception is raised.

    * TODO: [ ] Cache list of branches when git.branches/isbranch called
    * TODO: [ ] API change - git_run_status should raise an exception on failure and just return the git output
"""
################################################################################

import os
import sys
import re
import logging
import fnmatch
import subprocess
from functools import cache

import pygit2

from . import run

################################################################################
# Configuration files to access

(WORKTREE, LOCAL, GLOBAL, SYSTEM) = list(range(4))

# Options always passed to Git (disable autocorrect to stop it running the wrong command
# if an invalid command name has been specified).

STANDARD_GIT_OPTIONS = ['-c', 'help.autoCorrect=never']

# Default default branches (can be overridden in .gitconfig via skilleter-thingy.defaultBranches

DEFAULT_DEFAULT_BRANCHES = 'develop,main,master'

################################################################################

class GitError(run.RunError):
    """Run exception."""

    def __init__(self, msg, exit_status=1):
        """Initialize a git error.

        Args:
            msg: Error message.
            exit_status: Exit status code.

        Returns:
            None.
        """
        super().__init__(msg, exit_status)

################################################################################

def git(cmd, stdout=None, stderr=None, path=None):
    """Run a git command and raise `GitError` on failure.

    Args:
        cmd: Command list or string passed to git.
        stdout: Optional stdout stream override.
        stderr: Optional stderr stream override.
        path: Optional working directory for the git command.

    Returns:
        List of output lines from the command.

    Raises:
        GitError: If the git command fails or git is not found.
    """

    git_cmd = ['git'] + STANDARD_GIT_OPTIONS

    if path:
        git_cmd += ['-C', path]

    git_cmd += cmd if isinstance(cmd, list) else [cmd]

    logging.debug('Running %s', ' '.join(git_cmd))

    sys.stdout.flush()
    sys.stderr.flush()

    try:
        return run.run(git_cmd, stdout=stdout, stderr=stderr)
    except run.RunError as exc:
        raise GitError(exc.msg, exc.status) from exc
    except FileNotFoundError as exc:
        raise GitError(exc, 1) from exc

################################################################################

def git_run_status(cmd, stdout=None, stderr=None, path=None, redirect=True):
    """Run a git command and return output plus status code.

    Args:
        cmd: Command list or string passed to git.
        stdout: Optional stdout stream override.
        stderr: Optional stderr stream override.
        path: Optional working directory for the git command.
        redirect: If True, capture stdout/stderr.

    Returns:
        Tuple of (output, status_code).
    """

    logging.debug('Running git %s', ' '.join(cmd))

    git_cmd = ['git'] + STANDARD_GIT_OPTIONS

    if path:
        git_cmd += ['-C', path]

    git_cmd += cmd if isinstance(cmd, list) else [cmd]

    sys.stdout.flush()
    sys.stderr.flush()

    if redirect:
        result = subprocess.run(git_cmd,
                                stdout=stdout or subprocess.PIPE,
                                stderr=stderr or subprocess.PIPE,
                                text=True, check=False,
                                errors='ignore',
                                universal_newlines=True)
    else:
        result = subprocess.run(git_cmd, check=False)

    return (result.stdout or result.stderr), result.returncode

################################################################################

def clone(reponame, working_tree=None):
    """Clone a repository.

    Args:
        reponame: Repository URL or path.
        working_tree: Optional directory to clone into.

    Returns:
        List of output lines from git.
    """

    cmd = ['clone', reponame]

    if working_tree:
        cmd.append(working_tree)

    return git(cmd)

################################################################################

def init(reponame, bare=False, path=None):
    """Initialize a new git repository.

    Args:
        reponame: Name or path of the new repo.
        bare: If True, create a bare repository.
        path: Optional directory to run the command in.

    Returns:
        List of output lines from git.
    """

    cmd = ['init']

    if bare:
        cmd.append('--bare')

    cmd.append(reponame)

    return git(cmd, path=path)

################################################################################

def iscommit(commit, remote=False, remote_only=False, path=None):
    """Return True if a commit reference exists.

    Args:
        commit: Commit SHA, branch, or tag to check.
        remote: If True, also check remotes when no local match exists.
        remote_only: If True, only check remote branches.
        path: Optional working directory.

    Returns:
        True if the reference exists, otherwise False.
    """

    # Fail on empty commit

    if not commit:
        return False

    # Optionally look for an exact matching local branch or commit

    if not remote_only:
        try:
            git(['cat-file', '-t', commit], path=path)
            return True

        except GitError:
            pass

    # Optionally look for matching remote branch

    if remote or remote_only:
        remote_branches = branches(all=True, path=path, remote=True)

        for remote_name in remote_names(path=path):
            if f'{remote_name}/{commit}' in remote_branches:
                return True

    # Nothing found

    return False

################################################################################

def branch(branchname='HEAD', path=None):
    """Return the name of the current git branch.

    Args:
        branchname: Symbolic ref to resolve.
        path: Optional working directory.

    Returns:
        Branch name or None if detached.
    """

    try:
        return git(['symbolic-ref', '--short', '-q', branchname], path=path)[0]
    except GitError:
        return None

################################################################################

def tag(path=None):
    """Return tags pointing at the current commit.

    Args:
        path: Optional working directory.

    Returns:
        Tag name or None if not tagged.
    """

    try:
        return git(['describe', '--tags', '--exact-match'], path=path)[0]
    except GitError:
        return None

################################################################################

def tags(path=None):
    """Return the list of tags in the repository.

    Args:
        path: Optional working directory.

    Returns:
        List of tag names.
    """

    return git(['tag', '--list'], path=path)

################################################################################

def tag_delete(tag, push=False, path=None):
    """Delete a tag, optionally pushing the deletion.

    Args:
        tag: Tag name to delete.
        push: If True, push the deletion to origin.
        path: Optional working directory.

    Returns:
        None.
    """

    git(['tag', '-d', tag], path=path)

    if push:
        git(['push', 'origin', '--delete', tag], path=path)

################################################################################

def tag_apply(tag, commit=None, push=False, path=None):
    """Create a tag, optionally pushing it.

    Args:
        tag: Tag name to create.
        commit: Optional commit reference.
        push: If True, push the tag to origin.
        path: Optional working directory.

    Returns:
        None.
    """

    cmd = ['tag', tag]

    if commit:
        cmd.append(commit)

    git(cmd, path=path)

    if push:
        git(['push', 'origin', tag], path=path)

################################################################################

def tag_exists(tag, remote=None, path=None):
    """Check for the existence of a local or remote tag.

    Args:
        tag: Tag name to check.
        remote: Remote name to check, or None for local.
        path: Optional working directory.

    Returns:
        True if the tag exists, otherwise False.

    Raises:
        GitError: If git returns an unexpected error when checking a remote.
    """

    if remote:
        cmd = ['ls-remote', '--exit-code', remote, f'refs/tags/{tag}']
        output, status = git_run_status(cmd, path=path)

        if status == 0:
            return True

        if status == 2:
            return False

        raise GitError(output)

    cmd = ['tag', '--list', tag]

    output = git(cmd, path=path)

    if output:
        return True

    return False

################################################################################

def current_commit(short=False, path=None):
    """Return the SHA1 of the current commit.

    Args:
        short: If True, return the short SHA.
        path: Optional working directory.

    Returns:
        Commit SHA string.
    """

    cmd = ['rev-parse']

    if short:
        cmd.append('--short')

    cmd.append('HEAD')

    return git(cmd, path=path)[0]

################################################################################

def pull(repo=None, all=False, path=None):
    """Run `git pull`.

    Args:
        repo: Optional repository or remote name.
        all: If True, pull all remotes.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['pull']

    if all:
        cmd.append('--all')

    if repo:
        cmd.append(repo)

    return git(cmd, path=path)

################################################################################

def checkout(branch, create=False, commit=None, path=None):
    """Check out a branch.

    Args:
        branch: Branch name to check out.
        create: If True, create the branch.
        commit: Optional commit to create the branch from.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['checkout']

    if create or commit:
        cmd.append('-b')

    cmd.append(branch)

    if commit:
        cmd.append(commit)

    return git(cmd, path=path)

################################################################################

def merge(branch, path=None):
    """Merge a branch into the current branch.

    Args:
        branch: Branch name to merge.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['merge', branch]

    return git(cmd, path=path)

################################################################################

def abort_merge(path=None):
    """Abort the current merge.

    Args:
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    return git(['merge', '--abort'], path=path)

################################################################################

def set_upstream(branch, upstream=None, path=None):
    """Set the default upstream branch for a local branch.

    Args:
        branch: Local branch name.
        upstream: Upstream name to set. Defaults to ``origin/<branch>``.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    if not upstream:
        upstream = f'origin/{branch}'

    cmd = ['branch', f'--set-upstream-to={upstream}', branch]

    return git(cmd, path=path)

################################################################################

def fetch(all=False, path=None):
    """Run `git fetch`.

    Args:
        all: If True, fetch all remotes.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['fetch']

    if all:
        cmd.append('--all')

    return git(cmd, path=path)

################################################################################

def rebase_required(branch, parent, path=None):
    """Return True if a branch needs rebasing against its parent.

    Args:
        branch: Branch name to check.
        parent: Parent branch name.
        path: Optional working directory.

    Returns:
        True if a rebase is required, otherwise False.
    """

    # Find the latest commit on the parent branch and the most recent commit
    # that both branches have in common.

    parent_tip = git(['show-ref', '--heads', '-s', parent], path=path)
    common_commit = git(['merge-base', parent, branch], path=path)

    # Different commits, so rebase is required

    return parent_tip[0] != common_commit[0]

################################################################################

def rebase(branch, path=None):
    """Rebase the current branch against another branch.

    Args:
        branch: Branch name to rebase onto.
        path: Optional working directory.

    Returns:
        Tuple of (output, status_code).
    """

    return git_run_status(['rebase', branch], path=path)

################################################################################

def abort_rebase(path=None):
    """Abort the current rebase.

    Args:
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    return git(['rebase', '--abort'], path=path)

################################################################################

def rebasing(path=None):
    """Return True if a rebase is in progress.

    Args:
        path: Optional working directory.

    Returns:
        True if rebasing, otherwise False.
    """

    gitdir = git_dir(path=path)

    return os.path.isdir(os.path.join(gitdir, 'rebase-apply')) or \
        os.path.isdir(os.path.join(gitdir, 'rebase-merge'))

################################################################################

def bisecting(path=None):
    """Return True if a bisect is in progress.

    Args:
        path: Optional working directory.

    Returns:
        True if bisecting, otherwise False.
    """

    gitdir = git_dir(path=path)

    return os.path.isfile(os.path.join(gitdir, 'BISECT_START'))

################################################################################

def merging(path=None):
    """Return True if a merge is in progress.

    Args:
        path: Optional working directory.

    Returns:
        True if merging, otherwise False.
    """

    gitdir = git_dir(path=path)

    return os.path.isfile(os.path.join(gitdir, 'MERGE_MODE'))

################################################################################

def remotes(path=None):
    """Return a mapping of git remotes to their URLs.

    Args:
        path: Optional working directory.

    Returns:
        Dict mapping remote names to URLs.
    """

    repo = pygit2.Repository(git_dir(path=path))

    git_remotes = {}

    for name in repo.remotes.names():
        git_remotes[name] = repo.remotes[name].url

    return git_remotes

################################################################################

def remote_names(path=None):
    """Return the list of remote names.

    Args:
        path: Optional working directory.

    Returns:
        List of remote names.
    """

    repo = pygit2.Repository(git_dir(path=path))

    results = list(repo.remotes.names())

    return results

################################################################################

def project(short=False, path=None):
    """Return the name of the current git project.

    Args:
        short: If True, return only the base name.
        path: Optional working directory.

    Returns:
        Project name string (may be empty if not determined).
    """

    git_remotes = remotes(path=path)
    name = ''

    for remote in git_remotes:
        try:
            if '//' in git_remotes[remote]:
                name = git_remotes[remote].split('//')[-1].split('/', 1)[-1]
                break

            if '@' in git_remotes[remote]:
                name = git_remotes[remote].split(':')[-1]
                break

        except ValueError:
            continue

    if name.endswith('.git'):
        name = name[:-4]

    if short:
        name = os.path.basename(name)

    return name

################################################################################

def status_info(ignored=False, untracked=False, path=None, all_untracked=False):
    """Return git status as a dictionary of filename -> status.

    Args:
        ignored: If True, include ignored files.
        untracked: If True, include untracked files.
        path: Optional working directory.
        all_untracked: If True, include all untracked files.

    Returns:
        Dict mapping filenames to two-character git status codes.
    """

    cmd = ['status', '-z']

    if ignored:
        cmd.append('--ignored')

    if all_untracked:
        cmd.append('--untracked-files=all')

    results = git(cmd, path=path)

    # Dictionary of results, indexed by filename where the status is 2 characters
    # the first representing the state of the file in the index and the second the state
    # of the file in the working tree where:
    # M=modified, A=added, D=deleted, R=renamed, C=copied, U=unmerged, ?=untracked, !=ignored
    # Where a file has been renamed we don't report the original name

    info = {}

    if results:
        result = results[0].split('\0')

        for r in result:
            if len(r) > 3 and r[2] == ' ':
                git_status = r[0:2]
                name = r[3:]

                info[name] = git_status

    return info

################################################################################

def status(ignored=False, untracked=False, path=None, all_untracked=False):
    """Return git status as a list of status records.

    Args:
        ignored: If True, include ignored files.
        untracked: If True, include untracked files.
        path: Optional working directory.
        all_untracked: If True, include all untracked files.

    Returns:
        List of status records: [status, name, oldname?].
    """

    cmd = ['status', '--porcelain']

    if ignored:
        cmd.append('--ignored')

    if all_untracked:
        cmd.append('--untracked-files=all')

    results = git(cmd, path=path)

    # Nested list of results. For each entry:
    # item 0 is the status where: M=modified, A=added, D=deleted, R=renamed, C=copied, U=unmerged, ?=untracked, !=ignored
    # item 1 is the name
    # item 2 (if present) is the old name in cases where a file has been renamed.

    # TODO: This can't handle the case where a file has ' -> ' in the filename
    # TODO: This can't handle the case where a filename is enclosed in double quotes in the results

    info = []

    for result in results:
        stats = []
        stats.append(result[0:2])

        if not untracked and result[0] == '?':
            continue

        name = result[3:]
        if ' -> ' in name:
            stats += name.split(' -> ', 1)
        else:
            stats.append(name)

        info.append(stats)

    return info

################################################################################

def working_tree(path=None):
    """Return the path to the current working tree.

    Args:
        path: Optional working directory.

    Returns:
        Absolute path to the working tree or None if not in a repo.
    """

    repo_dir = git_dir(path=path)

    if repo_dir:
        return os.path.abspath(os.path.join(repo_dir, os.pardir))

    return None

################################################################################

def git_dir(path=None):
    """Return the path to the .git directory.

    Args:
        path: Optional working directory.

    Returns:
        Path to the .git directory, or None if not in a repo.
    """

    if not path:
        path = os.getcwd()

    return pygit2.discover_repository(path)

################################################################################

def tree_path(filename, path=None):
    """Normalize a filename to be relative to the repo root.

    Args:
        filename: Absolute or relative file path.
        path: Optional working directory.

    Returns:
        Path relative to the repo root.
    """

    git_tree = working_tree(path=path)

    return os.path.relpath(filename, git_tree)

################################################################################

def difftool(commit_1=None, commit_2=None, files=None, tool=None, path=None):
    """Run `git difftool`.

    Args:
        commit_1: First commit reference.
        commit_2: Second commit reference.
        files: Optional file or list of files to diff.
        tool: Optional difftool name.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['difftool']

    if tool:
        cmd += ['--tool', tool]

    if commit_1:
        cmd.append(commit_1)

    if commit_2:
        cmd.append(commit_2)

    if files:
        cmd.append('--')

        if isinstance(files, str):
            cmd.append(files)
        else:
            cmd += files

    return git(cmd, path=path)

################################################################################

# Match 'git diff --numstat' output - first re splits into lines added, removed
# and name. Second one is used if a file has been renamed, to get the old and
# new name components.

_DIFF_OUTPUT_RE = re.compile(r'(-|\d+)\s+(-|\d+)\s+(.*)')
_DIFF_OUTPUT_RENAME_RE = re.compile(r'(.*)\{(.*) => (.*)\}(.*)')

def commit_info(commit_1=None, commit_2=None, paths=None, diff_stats=False, path=None):
    """Return details of changes in a commit or between commits.

    Args:
        commit_1: First commit reference (defaults to HEAD if None).
        commit_2: Second commit reference.
        paths: Optional path or list of paths to restrict the diff.
        diff_stats: If True, include line statistics.
        path: Optional working directory.

    Returns:
        Dict keyed by filename with status and optional stats.
    """

    def parse_diff_output(result):
        """Extract previous and current filename (which may be the same) and lines added/deleted
           from output from git diff --numstat"""

        p = _DIFF_OUTPUT_RE.fullmatch(result)

        # This shouldn't happen...

        if not p:
            return 'ERROR', 'ERROR', 0, 0

        # Extract number of lines added/removed

        lines_ins = 0 if p.group(1) == '-' else int(p.group(1))
        lines_del = 0 if p.group(2) == '-' else int(p.group(2))

        # Check for rename and get both old and new names
        # The replace of '//' with '/' is to cope with the case where a file is moved
        # to a parent or subdirectory where the git output will be 'PARENT/{SUBDIR => }/NAME'
        # and the new name would otherwise be 'PARENT//NAME'

        if ' => ' in p.group(3):
            q = _DIFF_OUTPUT_RENAME_RE.fullmatch(p.group(3))

            if q:
                old_filename = (q.group(1) + q.group(2) + q.group(4)).replace('//', '/')
                new_filename = (q.group(1) + q.group(3) + q.group(4)).replace('//', '/')
            else:
                old_filename, new_filename = p.group(3).split(' => ')
        else:
            old_filename = new_filename = p.group(3)

        return old_filename, new_filename, lines_ins, lines_del

    # Either get changes between the two commits, or changes in the specified commit

    params = []

    if commit_1:
        params.append(commit_1)

    if commit_2:
        params.append(commit_2)

    if paths:
        params.append('--')

        if isinstance(paths, str):
            params.append(paths)
        else:
            params += paths

    results = git(['diff', '--name-status'] + params, path=path)

    # Parse the output

    info = {}

    for result in results:
        if result == '':
            continue

        # Renames and copies have an extra field (which we ignore) for the destination
        # file. We just get the status (Add, Move, Delete, Type-change, Copy, Rename)
        # and the name.

        if result[0] in ('R', 'C'):
            filestatus, oldname, filename = result.split('\t')
            info[filename] = {'status': filestatus[0], 'oldname': oldname}
        else:
            filestatus, filename = result.split('\t')
            info[filename] = {'status': filestatus[0], 'oldname': filename}

    # Add the diff stats, if requested

    if diff_stats:
        # Run git diff to get stats, and add them to the info
        # TODO: Need to extract old name of renamed files

        results = git(['diff', '--numstat'] + params, path=path)

        for result in results:
            _, new_filename, lines_ins, lines_del = parse_diff_output(result)

            info[new_filename]['deleted'] = lines_del
            info[new_filename]['added'] = lines_ins

        # Run git diff to get stats ignoring whitespace changes and add them

        results = git(['diff', '--numstat', '--ignore-all-space', '--ignore-blank-lines'] + params, path=path)

        for result in results:
            old_filename, new_filename, lines_ins, lines_del = parse_diff_output(result)

            info[new_filename]['non-ws deleted'] = lines_del
            info[new_filename]['non-ws added'] = lines_ins

        # Fill in the blanks - files

        for filename in info:
            if 'deleted' not in info[filename]:
                info[filename]['deleted'] = info[filename]['added'] = 0

            if 'non-ws deleted' not in info[filename]:
                info[filename]['non-ws deleted'] = info[filename]['non-ws added'] = 0

    return info

################################################################################

def diff(commit=None, renames=True, copies=True, relative=False, path=None):
    """Return a list of differences for the given commit context.

    Args:
        commit: Commit reference or list of two commits.
        renames: If True, detect renames.
        copies: If True, detect copies.
        relative: If True, show relative paths.
        path: Optional working directory.

    Returns:
        List of diff entries from git.
    """

    if commit:
        if isinstance(commit, list):
            if len(commit) > 2:
                raise GitError('git.diff - invalid parameters')

        else:
            commit = [commit]
    else:
        commit = []

    cmd = ['diff', '--name-status']

    if renames:
        cmd.append('--find-renames')

    if copies:
        cmd.append('--find-copies')

    if relative:
        cmd.append('--relative')

    cmd += commit

    return git(cmd, path=path)

################################################################################

def diff_status(commit1, commit2='HEAD', path=None):
    """Return True if there are no differences between two commits.

    Args:
        commit1: First commit reference.
        commit2: Second commit reference.
        path: Optional working directory.

    Returns:
        True if no differences exist, otherwise False.
    """

    cmd = ['diff', '--no-patch', '--exit-code', commit1, commit2]

    try:
        git(cmd, path=path)
    except GitError:
        return False

    return True

################################################################################

def show(revision, filename, outfile=None, path=None):
    """Return the contents of a file at a given revision.

    Args:
        revision: Commit or ref name.
        filename: Path to the file in the repository.
        outfile: Optional output stream for git.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    return git(['show', f'{revision}:{filename}'], stdout=outfile, path=path)

################################################################################

def add(files, path=None):
    """Add files to the git index.

    Args:
        files: List of file paths to add.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    return git(['add'] + files, path=path)

################################################################################

def rm(files, path=None):
    """Remove files from the repository.

    Args:
        files: List of file paths to remove.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    return git(['rm'] + files, path=path)

################################################################################

def commit(files=None,
           message=None,
           all=False, amend=False, foreground=False, patch=False, dry_run=False,
           path=None):
    """Commit files to git.

    Args:
        files: Optional list of files to commit.
        message: Commit message.
        all: If True, commit all tracked files.
        amend: If True, amend the previous commit.
        foreground: If True, run in foreground and stream output.
        patch: If True, use interactive patch mode.
        dry_run: If True, do not actually commit.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['commit']

    if amend:
        cmd.append('--amend')

    if all:
        cmd.append('--all')

    if patch:
        cmd.append('--patch')
        foreground = True

    if dry_run:
        cmd.append('--dry-run')

    if files is not None:
        cmd += files

    if message:
        cmd += ['-m', message]

    if foreground:
        return git(cmd, stdout=sys.stdout, stderr=sys.stderr, path=path)

    return git(cmd, path=path)

################################################################################

def push(all=False, mirror=False, tags=False, atomic=False, dry_run=False,
         follow_tags=False, receive_pack=False, repo=None, force=False, delete=False,
         prune=False, verbose=False, set_upstream=False, push_options=None, signed=None,
         force_with_lease=False, no_verify=False, repository=None, refspec=None,
         path=None):
    """Push commits to a remote.

    Args:
        all: If True, push all branches.
        mirror: If True, mirror all refs.
        tags: If True, push tags.
        atomic: If True, use atomic push.
        dry_run: If True, do not actually push.
        follow_tags: If True, push annotated tags that are missing.
        receive_pack: Optional receive-pack override.
        repo: Optional repo value for `--repo`.
        force: If True, force push.
        delete: If True, delete refs.
        prune: If True, prune remote-tracking refs.
        verbose: If True, use verbose output.
        set_upstream: If True, set upstream for pushed branch.
        push_options: Iterable of push options.
        signed: Optional signed mode.
        force_with_lease: If True, use force-with-lease.
        no_verify: If True, bypass pre-push hook.
        repository: Optional repository name.
        refspec: Optional list of refspecs.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['push']

    if all:
        cmd.append('--all')

    if mirror:
        cmd.append('--mirror')

    if tags:
        cmd.append('--tags')

    if atomic:
        cmd.append('--atomic')

    if dry_run:
        cmd.append('--dry-run')

    if follow_tags:
        cmd.append('--follow-tags')

    if receive_pack:
        cmd.append(f'--receive-pack={receive_pack}')

    if repo:
        cmd.append(f'--repo={repo}')

    if force:
        cmd.append('--force')

    if delete:
        cmd.append('--delete')

    if prune:
        cmd.append('--prune')

    if verbose:
        cmd.append('--verbose')

    if set_upstream:
        cmd.append('--set-upstream')

    if push_options:
        for option in push_options:
            cmd.append(f'--push-option={option}')

    if signed:
        cmd.append(f'--signed={signed}')

    if force_with_lease:
        cmd.append('--force-with-lease')

    if no_verify:
        cmd.append('--no-verify')

    if repository:
        cmd.append(repository)

    if refspec:
        for ref in refspec:
            cmd.append(ref)

    return git(cmd, path=path)

################################################################################

def reset(sha1, path=None):
    """Run `git reset`.

    Args:
        sha1: Commit reference to reset to.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    return git(['reset', sha1], path=path)

################################################################################

@cache
def config_get(section, key, source=None, defaultvalue=None, path=None):
    """Return the specified configuration entry.

    Args:
        section: Config section name.
        key: Config key name.
        source: One of WORKTREE, LOCAL, GLOBAL, SYSTEM, or None.
        defaultvalue: Value to return if the key is not found.
        path: Optional working directory.

    Returns:
        Config value or `defaultvalue` if not found.
    """

    cmd = ['config']

    if source == GLOBAL:
        cmd.append('--global')
    elif source == SYSTEM:
        cmd.append('--system')
    elif source == LOCAL:
        cmd.append('--local')
    elif source == WORKTREE:
        cmd.append('--worktree')

    cmd += ['--get', f'{section}.{key}']

    try:
        return git(cmd, path=path)[0]
    except GitError:
        return defaultvalue

################################################################################

def config_set(section, key, value, source=None, path=None):
    """Set a configuration entry.

    Args:
        section: Config section name.
        key: Config key name.
        value: Value to set.
        source: One of WORKTREE, LOCAL, GLOBAL, SYSTEM, or None.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    config_get.cache_clear()

    cmd = ['config']

    if source == GLOBAL:
        cmd.append('--global')
    elif source == SYSTEM:
        cmd.append('--system')
    elif source == LOCAL:
        cmd.append('--local')
    elif source == WORKTREE:
        cmd.append('--worktree')

    cmd += ['--replace-all', f'{section}.{key}', value]

    return git(cmd, path=path)

################################################################################

def config_rm(section, key, source=LOCAL, path=None):
    """Remove a configuration entry.

    Args:
        section: Config section name.
        key: Config key name.
        source: One of LOCAL, GLOBAL, SYSTEM.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['config']

    if source == GLOBAL:
        cmd.append('--global')
    elif source == SYSTEM:
        cmd.append('--system')

    cmd += ['--unset', f'{section}.{key}']

    return git(cmd, path=path)

################################################################################

def ref(fields=('objectname',), sort=None, remotes=False, path=None):
    """Iterate refs using `git for-each-ref`.

    Args:
        fields: Iterable of fields to include in the output.
        sort: Optional sort key.
        remotes: If True, include origin remotes.
        path: Optional working directory.

    Yields:
        Lists of field values per ref.
    """

    cmd = ['for-each-ref']

    if sort:
        cmd.append(f'--sort={sort}')

    field_list = []
    for field in fields:
        field_list.append('%%(%s)' % field)

    cmd += ['--format=%s' % '%00'.join(field_list), 'refs/heads']

    if remotes:
        cmd.append('refs/remotes/origin')

    for output in git(cmd, path=path):
        yield output.split('\0')

################################################################################

def branches(all=False, path=None, remote=False):
    """Return a list of branches in the repository.

    Args:
        all: If True, include all branches.
        path: Optional working directory.
        remote: If True, include remote branches.

    Returns:
        List of branch names.
    """

    cmd = ['branch', '--format=%(refname:short)', '--list']

    if all:
        cmd.append('--all')

    if remote:
        cmd.append('--remote')

    results = []
    for output in git(cmd, path=path):
        if ' -> ' not in output and '(HEAD detached at ' not in output:
            results.append(output)

    return results

################################################################################

def delete_branch(branch, force=False, remote=False, path=None):
    """Delete a branch.

    Args:
        branch: Branch name to delete.
        force: If True, force delete.
        remote: If True, delete the remote tracking branch.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['branch', '--delete']

    if force:
        cmd.append('--force')

    if remote:
        cmd.append('--remote')

    cmd.append(branch)

    return git(cmd, path=path)

################################################################################

def remote_prune(remote, dry_run=False, path=None):
    """Return a list of prunable remote tracking branches.

    Args:
        remote: Remote name.
        dry_run: If True, do not actually prune.
        path: Optional working directory.

    Returns:
        List of prunable remote branch names.
    """

    cmd = ['remote', 'prune', remote]

    if dry_run:
        cmd.append('--dry-run')

    results = git(cmd, path=path)

    prunable_branches = []

    prune_re = re.compile(r'\s[*]\s\[would prune\]\s(.*)')

    for result in results:
        matches = prune_re.match(result)
        if matches:
            prunable_branches.append(matches.group(1))

    return prunable_branches

################################################################################

def get_commits(commit1, commit2, path=None):
    """Get a list of commits separating two commits.

    Args:
        commit1: Starting commit reference.
        commit2: Ending commit reference.
        path: Optional working directory.

    Returns:
        List of commit SHAs.
    """

    return git(['rev-list', commit1, f'^{commit2}'], path=path)

################################################################################

def commit_count(commit1, commit2, path=None):
    """Get a count of commits between two commits.

    Args:
        commit1: Starting commit reference.
        commit2: Ending commit reference.
        path: Optional working directory.

    Returns:
        Number of commits as an int.
    """

    return int(git(['rev-list', '--count', commit1, f'^{commit2}'], path=path)[0])

################################################################################

def branch_name(branch, path=None):
    """Return the full name of a branch reference.

    Args:
        branch: Branch name or abbreviation (e.g. ``@{upstream}``).
        path: Optional working directory.

    Returns:
        Full branch name.
    """

    return git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', branch], path=path)[0]

################################################################################

def author(commit, path=None):
    """Return the author of a commit.

    Args:
        commit: Commit reference.
        path: Optional working directory.

    Returns:
        Author name string.
    """

    return git(['show', '--format=format:%an', commit], path=path)[0]

################################################################################

def commit_changes(commit='HEAD', path=None):
    """Return a list of files changed in a commit.

    Args:
        commit: Commit reference.
        path: Optional working directory.

    Returns:
        List of file paths.
    """

    return git(['show', '--name-only', '--pretty=format:', commit], path=path)

################################################################################

def files(dir=None, path=None):
    """Return the output from `git ls-files`.

    Args:
        dir: Optional directory to list.
        path: Optional working directory.

    Returns:
        List of file paths tracked by git.
    """

    cmd = ['ls-files']

    if dir:
        cmd.append(dir)

    return git(cmd, path=path)

################################################################################

def stash(path=None):
    """Return the list of stashed items.

    Args:
        path: Optional working directory.

    Returns:
        List of stash entries.
    """

    cmd = ['stash', 'list']

    return git(cmd, path=path)

################################################################################

def parents(commit=None, ignore=None, path=None):
    """Find possible parent branches for a commit.

    Args:
        commit: Commit or branch reference to analyze.
        ignore: Optional glob to ignore branches.
        path: Optional working directory.

    Returns:
        Tuple of (list of parent branch names and distance or None, 0).
    """

    # Get the history of the branch

    current_branch = commit or branch('HEAD', path=path)

    current_history = git(['rev-list', current_branch], path=path)

    # Look down the commits on the current branch for other branches that have
    # the same commit, using the ignore pattern if there is one.

    for distance, ancestor in enumerate(current_history):
        branches = []
        for brnch in git(['branch', '--contains', ancestor], path=path):
            brnch = brnch[2:]
            if brnch != current_branch and '(HEAD detached at' not in brnch:
                if not ignore or (ignore and not fnmatch.fnmatch(brnch, ignore)):
                    branches.append(brnch)

        if branches:
            break
    else:
        return None, 0

    return branches, distance

################################################################################

def find_common_ancestor(branch1='HEAD', branch2='master', path=None):
    """Find the common ancestor of two branches.

    Args:
        branch1: First branch reference.
        branch2: Second branch reference.
        path: Optional working directory.

    Returns:
        Commit SHA of the common ancestor.
    """

    return git(['merge-base', branch1, branch2], path=path)[0]

################################################################################

# List of boolean options to git.grep with corresponding command line option to use if option is True

_GREP_OPTLIST = \
    (
        ('color', '--color=always'),
        ('count', '--count'),
        ('follow', '--follow'),
        ('unmatch', '-I'),
        ('textconf', '--textconv'),
        ('ignore_case', '--ignore-case'),
        ('word_regexp', '--word-regexp'),
        ('invert_match', '--invert-match'),
        ('full_name', '--full-name'),
        ('extended_regexp', '--extended-regexp'),
        ('basic_regexp', '--basic-regexp'),
        ('perl_regexp', '--perl-regexp'),
        ('fixed_strings', '--fixed-strings'),
        ('line_number', '--line-number'),
        ('files_with_matches', '--files-with-matches'),
        ('files_without_match', '--files-without-match'),
        ('names_only', '--names-only'),
        ('null', '--null'),
        ('all_match', '--all-match'),
        ('quiet', '--quiet'),
        ('no_color', '--no-color'),
        ('break', '--break'),
        ('heading', '--heading'),
        ('show_function', '--show-function'),
        ('function_context', '--function-context'),
        ('only_matching', '--only-matching'),
    )

# List of non-boolean options to git.grep with corresponding command line option

_GREP_NON_BOOL_OPTLIST = \
    (
        ('root', '--root'),
        ('max_depth', '--max-depth'),
        ('after_context', '--after-context'),
        ('before_context', '--before-context'),
        ('context', '--context'),
        ('threads', '--threads'),
        ('file', '--file'),
        ('parent_basename', '--parent-basename')
    )

def grep(pattern, git_dir=None, work_tree=None, options=None, wildcards=None, path=None):
    """Run `git grep` with options passed as a dictionary.

    Args:
        pattern: Pattern or list of patterns to search for.
        git_dir: Optional git directory path.
        work_tree: Optional work tree path.
        options: Dict of grep options.
        wildcards: Optional list of pathspecs.
        path: Optional working directory.

    Returns:
        Tuple of (output, status_code).
    """

    cmd = []

    if git_dir:
        cmd += ['--git-dir', git_dir]

    if work_tree:
        cmd += ['--work-tree', work_tree]

    cmd += ['grep']

    if options:
        # Boolean options

        for opt in _GREP_OPTLIST:
            if options.get(opt[0], False):
                cmd.append(opt[1])

        # Non-boolean options

        for opt in _GREP_NON_BOOL_OPTLIST:
            value = options.get(opt[0], None)
            if value:
                cmd += (opt[1], value)

    if isinstance(pattern, list):
        cmd += pattern
    else:
        cmd += [pattern]

    if wildcards:
        cmd.append('--')
        cmd += wildcards

    return git_run_status(cmd, path=path)

################################################################################

def isbranch(branchname, path=None):
    """Return True if the specified branch exists.

    Args:
        branchname: Branch name to check.
        path: Optional working directory.

    Returns:
        True if the branch exists, otherwise False.
    """

    return branchname in branches(True, path=path)

################################################################################

def default_branches(path=None):
    """Get the list of default branch names.

    Args:
        path: Optional working directory.

    Returns:
        List of default branch names from skilleter-thingy.defaultBranches in the Git configuration.
    """

    return config_get('skilleter-thingy', 'defaultBranches', defaultvalue=DEFAULT_DEFAULT_BRANCHES, path=path).split(',')

################################################################################

def is_default_branch(branchname=None, path=None):
    """Return True if the specified branch is a default branch.

    Args:
        branchname: Branch name to check. Defaults to the current branch.
        path: Optional working directory.

    Returns:
        True if the branch is a default branch, otherwise False.
    """

    # Use the current branch if not specified

    if not branchname:
        branchname = branch(path=path)

    # Get the list of default branch wildcards

    defaults = default_branches(path=path)

    # If the branch matches any of the wildcards, return True

    for defbranch in defaults:
        if fnmatch.fnmatch(branchname, defbranch):
            return True

    # No match, so not a default branch

    return False

################################################################################

def default_branch(path=None, branches=None):
    """Return the name of the default branch, matching existing branches
       in the order of default branch names (so if we have 'main' and 'master'
       'main' would be the default as it is listed first (assuming no overrides
       in the git config)).

    Args:
        path: Optional working directory.
        branches: Optional list of branch names to consider.

    Returns:
        Name of the default branch.

    Raises:
        GitError: If no default branch can be determined.
    """

    if not branches:
        branches = branches(path=path)

    for branchmatch in default_branches(path=path):
        for branch in branches:
            if fnmatch.fnmatch(branch, branchmatch):
                return branch

    raise GitError('Unable to determine default branch in the repo')

################################################################################

def matching_branch(branchname, case=False, path=None):
    """Find branches matching the given name or pattern.
       If the branchname contains '*' or '?' wildcard matching is used,.
       otherwise, it just checks for a branches containing the branchname
       as a substring.

    Args:
        branchname: Name or pattern to match.
        case: If True, use case-sensitive matching.
        path: Optional working directory.

    Returns:
        List of matching branch names (possibly empty).
    """

    local_branches = branches(path=path)
    remote_branches = branches(path=path, remote=True)

    all_branches = local_branches + remote_branches

    # Always return exact matches

    if branchname in all_branches:
        return [branchname]

    matching = []
    matching_remote = []

    if not case:
        branchname = branchname.lower()

    if branchname == '-' * len(branchname):
        matching = [branchname]
    else:
        wildcard = '?' in branchname or '*' in branchname

        if wildcard:
            if branchname[0] not in ('?', '*'):
                branchname = f'*{branchname}'

            if branchname[-1] not in ('?', '*'):
                branchname = f'{branchname}*'

        for branch in all_branches:
            branchmatch = branch if case else branch.lower()

            # We have a partial match

            if (not wildcard and branchname in branchmatch) or (wildcard and fnmatch.fnmatch(branchmatch, branchname)):
                # If the match is a remote branch, ignore it if we already have the equivalent
                # local branch, otherwise add the name of the local branch that would be created.

                if branch in remote_branches:
                    localbranch = '/'.join(branch.split('/')[1:])
                    if localbranch not in matching:
                        matching_remote.append(localbranch)
                else:
                    matching.append(branch)

    # If we don't have matching local branches use the remote branch list (which may also be empty)

    if not matching:
        matching = matching_remote

    # Return the list of matches

    return matching

################################################################################

def update(clean=False, all=False, path=None):
    """Run `git update`.

    Args:
        clean: If True, pass --clean.
        all: If True, pass --all.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['update']

    if clean:
        cmd.append('--clean')

    if all:
        cmd.append('--all')

    return git(cmd, path=path)

################################################################################

def object_type(name, path=None):
    """Return the git object type.

    Args:
        name: Object name or SHA.
        path: Optional working directory.

    Returns:
        Object type string.
    """

    return git(['cat-file', '-t', name], path=path)[0]

################################################################################

def matching_commit(name, path=None):
    """Find branches, tags, or commits matching a name.

    Args:
        name: Name or partial name to match.
        path: Optional working directory.

    Returns:
        List of matches (possibly empty).
    """

    # First, look for exact matching object

    if iscommit(name, path=path):
        return [name]

    # Look for at least one matching branch

    matches = matching_branch(name, path=path)

    if matches:
        return matches

    # Look for at least one matching tag

    matches = []
    for tag in tags(path=path):
        if name in tag:
            matches.append(tag)

    if matches:
        return matches

    # Look for a matching commit

    try:
        commit_type = object_type(name, path=path)

        if commit_type == 'commit':
            matches = [name]
    except GitError:
        matches = []

    return matches

################################################################################

def log(branch1, branch2=None, path=None):
    """Return the git log between commits.

    Args:
        branch1: First commit reference.
        branch2: Optional second commit reference.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    if branch2:
        cmd = ['log', f'{branch1}...{branch2}']
    else:
        cmd = ['log', '-n1', branch1]

    return git(cmd, path=path)

################################################################################

def clean(recurse=False, force=False, dry_run=False, quiet=False,
          exclude=None, ignore_rules=False, remove_only_ignored=False, path=None):
    """Run `git clean`.

    Args:
        recurse: If True, remove directories.
        force: If True, force removal.
        dry_run: If True, show what would be removed.
        quiet: If True, suppress output.
        exclude: Optional exclusion pattern.
        ignore_rules: If True, ignore .gitignore rules.
        remove_only_ignored: If True, remove only ignored files.
        path: Optional working directory.

    Returns:
        List of output lines from git.
    """

    cmd = ['clean']

    if recurse:
        cmd.append('-d')

    if force:
        cmd.append('--force')

    if dry_run:
        cmd.append('--dry-run')

    if quiet:
        cmd.append('--quiet')

    if exclude:
        cmd += ['--exclude', exclude]

    if ignore_rules:
        cmd.append('-x')

    if remove_only_ignored:
        cmd.append('-X')

    return git(cmd, path=path)
