#!/usr/bin/env python3

import sys
import os
import os.path
import io
import stat
import hashlib
import subprocess
from subprocess import check_output, CalledProcessError
import re
import shutil
import time
import configparser
import textwrap

gitraw_usage = """\
usage: git raw <command> [--help | -h] [<args>]

git-raw is a plug-in for git that simplifies the process of storing and
managing large and/or binary files that need to be kept in sync with a
source code repository.
"""

# Each git-raw command is stored in a dictionary 'commands' with its
# name, as used on the command line, as the key. A '@command' decorator
# is used to streamline the insertion of each command into 'commands'.
#
# The 'main()' function will simply parse the command line and pass
# arguments to the available commands, while catching any resulting
# exceptions.
commands = {}

def command(func):
    """Function decorator for git-raw commands

    adds each decorated function to the global 'commands' dictionary
    using the function's name, with "_" replaced with "-" as key.

    Args:
        func: A python function that implements a git-raw command

    Returns:
        'func' unaltered
    """
    commands[func.__name__.replace("_", "-")] = func
    return func


# Exception classes
class GitRawError(Exception):
    def __init__(self, msg=""):
        self.msg = msg

    def __str__(self):
        return self.msg

class ImpossibleError(GitRawError):
    def __init__(self):
        super().__init__("unexpected failure")

class NotInRepoError(GitRawError):
    def __init__(self):
        super().__init__("not in a git repository")

class UnknownCommandError(GitRawError):
    def __init__(self, cmd):
        super().__init__("'" + cmd + "' is not a git-raw command")

class NotRawInitializedError(GitRawError):
    def __init__(self):
        super().__init__("repository is not raw-initialized\n" + "try 'git raw init'")

class BadRawStateError(GitRawError):
    def __init(self, msg):
        super().__init__(msg)


# utility functions
def perr(*args, **kwargs):
    """Print to sys.stderr

    Forwards the call to builtin print() function but sets the output
    stream to sys.stderr.
    """
    print(*args, file=sys.stderr, **kwargs)

def vprint(*args, **kwargs):
    """Print message if in verbose mode

    Forward call to builtin print() function if the environment variable
    GIT_RAW_VERBOSE is set.
    """
    if os.environ.get("GIT_RAW_VERBOSE") != None:
        print(*args, **kwargs)

def compute_sha1(filename):
    """Compute SHA1 hash for a file's content

    Args:
        filename: Path to a file

    Returns:
        A string representation of 'file's SHA1 hash
    """
    hashfun = hashlib.sha1()
    with open(file=filename, mode='rb') as f:
        chunk = f.read(io.DEFAULT_BUFFER_SIZE)
        while (len(chunk) > 0):
            hashfun.update(chunk)
            chunk = f.read(io.DEFAULT_BUFFER_SIZE)
    return hashfun.hexdigest()

def store_mkname(store_path, sha1sum):
    """Create a path based on a SHA1 hash

    Args:
        store_path: Path to a directory
        sha1sum: A string containing a SHA1 hash

    Returns:
        Path inside 'store_path' where content with SHA1 hash 'sha1sum'
        can be stored. If 'sha1sum' is "abcd..1234", the returned path
        will be "<store_path>/ab/abcd..1234/raw"
    """
    return os.path.join(store_path, sha1sum[0:2], sha1sum, "raw")

def chmod_readonly(path):
    """Unset all write permissions on 'path'

    Unset write permissions of user, group and other for the given
    path. This is a no-op if the write permissions are already unset.

    Args:
        path: Path to file or directory

    Returns:
        None
    """
    mode = os.stat(path)[stat.ST_MODE]
    new_mode = mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
    if mode != new_mode:
        os.chmod(path, new_mode)

def check_storename(name):
    """Check if 'name' can be used to name a content store

    The names used for content stores are restricted to alphanumeric
    characters plus "_" and "-". Also, "defaultAddStore" is reserved
    for defining a default store to be used with the 'add' command.

    Args:
        name: A string

    Returns:
        True if 'name' can be used to name a store and False otherwise
    """
    return re.match("[a-zA-Z0-9_-]+\\Z", name) != None and name != "defaultAddStore"

def get_usage(command):
    """parses out the usage string from 'doc_str'

    Args:
        command: A git-raw command with a __doc__ attribute.

    Returns:
        The second paragraph of 'command.__doc__'.
    """
    return textwrap.dedent(command.__doc__.split("\n\n", 2)[1]).rstrip()


class Git:
    """Git repository and git-raw state information

    An instance of Git provides attributes and methods to access and modify
    the state of the git repository in the current directory.

    Attributes:
        top_dir: Absolute path to the top-level git working directory
        git_dir: Absolute path to the '.git' directory
        gitraw_dir: Path inside '.git' containing all git-raw-associated files.
        index_dir: Path inside 'gitraw_dir' that holds symlinks to content stores
        config_file: Path to the git raw config file
        config: A ConfigParser object corresponding to the git-raw
            config file. Exists only if repository is git-raw-initialized.
        dummy_header: A string (Bytes) written to all dummy files created by
            the 'unlock' command
    """
    def __init__(self):
        """Check that we are inside a git repository and init a Git instance

        Raises:
            NotInRepoError: The current directory is not a git repository
            BadRawStateError: Git repository is raw-initialized, but
                something is wrong with the current configuration
        """
        try:
            output = check_output(["git", "rev-parse", "--is-inside-work-tree"]).rstrip(b"\n")
        except:
            raise NotInRepoError
        else:
            if output != b"true":
                raise NotInRepoError

        try:
            self.top_dir = check_output(["git", "rev-parse", "--show-toplevel"],
                                        universal_newlines=True).rstrip("\n")
        except CalledProcessError as err:
            raise ImpossibleError

        try:
            self.git_dir = check_output(["git", "rev-parse", "--git-dir"],
                                        universal_newlines=True).rstrip("\n")
            self.git_dir = os.path.abspath(self.git_dir)
        except CalledProcessError as err:
            raise ImpossibleError

        self.gitraw_dir = os.path.join(self.git_dir, "git-raw")
        self.index_dir = os.path.join(self.gitraw_dir, "index")
        self.config_file = os.path.join(self.gitraw_dir, "config")

        self.dummy_header = b"$git raw dummy$"

        if self.is_initialized():
            # create case-sensitive ConfigParser instance
            self.config = configparser.ConfigParser()
            self.config.optionxform = lambda option: option
            # read in the config file and ensure it is parsable
            try:
                self.config.read(self.config_file)
            except:
                raise BadRawStateError("Bad config file '" + self.config_file + "'")

            self.verify_raw_state()

    def is_initialized(self):
        """Check if repository is raw-initialized

        Returns:
            True if the current git repository is raw-initialized,
            False otherwise. No check is made for correctness or
            consistency of the configuration. Currently, we only check
            if the 'gitraw_dir' directory exists.
        """
        return os.path.isdir(self.gitraw_dir)

    def verify_raw_state(self):
        """Verify the current state of git-raw configuration

        Assumes that the current git repository has been
        raw-initialized. Ensures that all git-raw directories exists and
        that the config file is valid.

        Raises:
            BadRawStateError: Git repository is raw-initialized, but
                something is wrong with the current configuration
        """
        # ensure all required directories exits
        for rawdir in [self.gitraw_dir, self.index_dir]:
            if not os.path.exists(rawdir) or not os.path.isdir(rawdir):
                raise BadRawStateError("'" + rawdir + "' is missing")

        # a "stores" section is mandatory in config file
        if "stores" not in self.config:
            raise BadRawStateError("Corrupt config file: no 'stores' section")

        # check consistency of stores configuration variables
        stores = self.config["stores"]
        if len(stores) > 0:
            if "defaultAddStore" not in stores:
                raise BadRawStateError("Corrupt config file: no default store configured")

            if (stores["defaultAddStore"] not in stores or
                stores["defaultAddStore"] == "defaultAddStore"):
                raise BadRawStateError("Corrupt config file: bad default store")

    def get_git_config(self, varname):
        """Get value of a git config variable by calling "git config --get"

        Args:
            varname: A string containing the name of a config variable

        Returns:
            'None' if the variable is not set, otherwise the string
            returned by a call to "git config --get <varname>"
        """
        try:
            output = check_output(["git", "config", "--get", varname],
                                  universal_newlines=True,
                                  stderr=subprocess.STDOUT)
        except CalledProcessError as err:
            if err.returncode == 1 and err.output == "":
                return None
            elif err.returncode != 0:
                raise ImpossibleError

        return output.rstrip("\n")

    def path_is_in_repo(self, path):
        """Check if a path is inside the current git repository

        Args:
            path: A string containing a path

        Returns:
            True if 'path' points to location inside the current git
            repository, False otherwise.
        """
        abs_path = os.path.abspath(path)
        rel_path = os.path.relpath(abs_path, self.top_dir)
        if rel_path.startswith(os.pardir):
            return False
        else:
            return True

    def path_is_raw_link(self, path):
        """Check if a path is a symlink to a content store

        Args:
            path: A path

        Returns:
            True if 'path' is a symlink inside the current repository
            whose target is a relative path that ends with the pattern:

            /git-raw/index/ab/abc..123/raw

            and False otherwise.
        """
        dirname = os.path.dirname

        if not os.path.lexists(path) or not os.path.islink(path) or os.path.isdir(path):
            return False

        if not self.path_is_in_repo(path):
            return False

        target = os.readlink(path)

        if os.path.isabs(target):
            return False

        # check if the target of the symlink ends with something like
        # "/git-raw/index/ab/abcd..1234/raw"
        target_normalized = os.path.normpath(target)
        match =re.search(os.sep + "git-raw" + os.sep + "index" + os.sep +
                         "([0-9a-f]{2})" + os.sep + "\\1[0-9a-f]{38}" + os.sep + "raw$",
                         target_normalized)

        return match != None

    def path_is_raw_dummy(self, path):
        """Check if a path is a dummy file generated by the 'unlock' command

        Args:
            path: A path

        Returns:
            True if 'path' is a regular file containing the signature
            of a git raw dummy file, and False otherwise.
        """
        if os.path.islink(path) or not os.path.isfile(path):
            return False

        with open(path, "rb") as f:
            line1 = f.read(len(self.dummy_header))
            if line1 != self.dummy_header:
                return False

            f.read(1)  # remove new-line
            line2 = f.read(1000)
            if b" --> " not in line2:
                return False

        return True

    def add_to_index(self, store_path, sha1):
        """Add symlink to content in a store

        Args:
            store_path: A path to a content store
            sha1: A string with a SHA1 hash

        Returns:
            None

        Raises:
            OSError from failed filesystem operations
        """
        index_link = os.path.dirname(store_mkname(self.index_dir, sha1))
        store_raw = store_mkname(store_path, sha1)

        if os.path.lexists(index_link):
            os.remove(index_link)

        os.makedirs(os.path.dirname(index_link), exist_ok=True)
        os.symlink(os.path.dirname(store_raw), index_link)

    def find_sha1(self, sha1):
        """Find a store containing specified content

        Args:
            sha1: A string containing a SHA1 hash

        Returns:
            Path of a content store that contains the specified content,
            or None if content could not be found in any store.
        """
        for store_name, store_path in self.config["stores"].items():
            if os.path.isfile(store_mkname(store_path, sha1)):
                return store_path

        return None

    def ls_files_symlinks_in_index(self, paths):
        """Get all symlinks in git's index

        This is a simple wrapper around git's 'ls-files' command and
        parser and returns the output from

        git ls-files --cached --stage -z <path>...

        Args:
            paths: list of paths passed to git's 'ls-files' command

        Returns:
            List of paths corresponding to symlinks in the git index. If
            'paths' is an empty list, all symlinks in the index from the
            current working directory (recursively visiting
            subdirectories) are returned. Otherwise, only symlinks and
            content of directories passed in 'paths' are considered.
        """

        try:
            output = check_output(["git", "ls-files", "--cached", "--stage", "-z"] + paths,
                                  universal_newlines=True)
        except CalledProcessError as err:
            if not err.output.endswith("is outside repository\n"):
                raise ImpossibleError

            raise err
        links = [p.split(maxsplit=3)[-1] for p in output.split("\0") if p[0:2] == "12"]
        return links

    def ls_files_others(self, paths):
        """Get all paths (symlinks and files) not in git's index

        This is a simple wrapper around git's 'ls-files' command and
        parser and returns the output from

        git ls-files --others -z <path>...

        Args:
            paths: list of paths passed to git's 'ls-files' command

        Returns:
            List of paths (file or symlinks) returned by git's
            'ls-files' command.
        """
        try:
            output = check_output(["git", "ls-files", "--others", "-z"] + paths,
                                  universal_newlines=True)
        except CalledProcessError as err:
            raise ImpossibleError
        others = output.split("\0")
        return others


# git-raw commands
#
# Each git-raw command is implemented by a single function. Each such
# function takes two arguments: An instance 'git' of class 'Git' and a
# list 'args' of command-line arguments.
@command
def init(git, args):
    """Initialize git repository for use by git-raw

    usage: git raw init

    This command prepares the git repository of the current working
    directory for use with git-raw. It will create a 'git-raw' directory
    in the '.git' directory of the git repository. The 'git-raw'
    directory will contain all the configuration files and links to
    content stores that are used by git-raw.

    Currently, git-raw does not support using the $GIT_DIR environment variable.
    """
    if len(args) > 0:
        perr("error: unknown option")
        perr(get_usage(init))
        return 1

    if git.is_initialized():
        perr("Git repository is already raw-initialized")
        return 0

    # create directory structure under $GITDIR/git-raw
    dirs =  [git.gitraw_dir, git.index_dir]
    for path in dirs:
        try:
            os.makedirs(path, exist_ok=True)
        except OSError as err:
            perr("error: could not create directory '" + path + "'")
            perr(err.strerror)
            return 1

    # create an empty config file
    try:
        with open(git.config_file, "w") as cfg:
            config = configparser.ConfigParser()
            config.add_section("stores")
            config.write(cfg)
    except OSError as err:
        perr("error: could not create config file")
        perr(err.strerror)
        return 1

    # write instructions on how to add stores
    print("Initialized Git repository for git-raw")
    print("NOTE: no stores have been configured!")
    print("      use 'git raw add-store' to add content stores")

    return 0

@command
def add(git, args):
    """Add files to a content store and replace with a symlink

    usage: git raw add [--store <store-name>] <file>...

    Adds the content of each given file to a configured content store
    and replaces each file with a symlink, which is then staged for
    commit using Git's 'add' command.

    Unless a store is specified with the --store option, all content is
    added to the store that is configured as the default add store.

    The contents are stored as read-only files. To unlock the contents
    temporarily so that they can be overwritten, see the 'unlock'
    command. To replace a symlink with its original content, see the
    'revert' command.

    Options:

    --store <store-name>
        Add all content to the the store configured as <store-name>
    """
    if not git.is_initialized():
        raise NotRawInitializedError()

    default_store_name = git.config["stores"].get("defaultAddStore")
    paths = []
    i = 0
    while i < len(args):
        if args[i] == "--store":
            if i + 1 >= len(args):
                perr("error: --store expects a store name")
                perr(get_usage(add))
                return 1

            default_store_name = args[i + 1]
            i += 1
        else:
            paths.append(args[i])

        i += 1

    if len(paths) == 0:
        perr("error: no files specified")
        perr(get_usage(add))
        return 1

    if default_store_name == None:
        perr("error: no default store configured")
        perr(get_usage(add))
        return 1

    default_store_path = git.config["stores"].get(default_store_name)
    if default_store_path == None:
        perr("error: no such store '" + default_store_name + "'")
        return 1

    # check specified paths
    for path in paths:
        if os.path.islink(path):
            perr("error: '" + path + "' is a symlink")
            return 1

        if not os.path.lexists(path):
            perr("error: path '" + path + "' not found")
            return 1

        if os.path.isdir(path):
            perr("error: '" + path + "' is a directory")
            perr("recursively adding files in directories is not (yet) supported")
            perr("add each file separately")
            return 1

        if not git.path_is_in_repo(path):
            perr("error: '" + path + "' is outside repository")
            return 1

        if git.path_is_raw_dummy(path):
            perr("error: '" + path + "' is a placeholder for a git-raw file")
            perr("use git's checkout command to revert the file to a git-raw symlink")
            return 1

    for path in paths:
        path_sha1 = compute_sha1(path)
        path_size = os.path.getsize(path)
        path_abs = os.path.realpath(path)

        store_path = git.find_sha1(path_sha1)
        store_raw = None
        if store_path == None:
            store_path = default_store_path
            store_raw = store_mkname(store_path, path_sha1)
            try:
                os.makedirs(os.path.dirname(store_raw), exist_ok=True)
            except OSError as err:
                perr("error: unable to create directories for '" + store_raw + "'")
                perr(err.strerror)
                return 1
            try:
                shutil.move(path, store_raw) 
            except OSError as err:
                perr("error: unable to move '" + path + "' to the store")
                perr(err.strerror)
                return 1
        else:
            store_raw = store_mkname(store_path, path_sha1)

            if os.path.getsize(store_raw) != path_size:
                # This is a serious error and should never happen.  Either
                # we have a sha1 collision (extremely unlikely) or the file
                # already in the store is corrupt.
                perr("serious error: content of '" + path + "' already exists in content store as:")
                perr("    " + store_raw)
                perr("but their sizes do not match.")
                perr("*** The content in the store may be corrupt! ***")
                return 2

            try:
                os.remove(path)
            except OSError as err:
                perr("error: unable to replace '" + path + "' with symlink")
                perr(err.strerror)
                return 1

        try:
            git.add_to_index(store_path, path_sha1)
        except OSError as err:
            perr("error: unable to add symlink to the index")
            perr(err.strerror)
            return 1

        try:
            target = os.path.relpath(store_mkname(git.index_dir, path_sha1),
                                     os.path.dirname(path_abs))
            os.symlink(target, path_abs)
        except OSError as err:
            perr("error: unable to create symlink")
            perr(path + " --> " + target)
            perr(err.strerror)
            return 1

        # write to log file
        log_file = os.path.join(os.path.dirname(store_raw), "log")
        with open(log_file, "a") as logfile:
            logfile.write(time.strftime("%Y-%m-%d %H:%M:%S" + "\t"))
            logfile.write("add" + "\t")
            logfile.write(os.path.basename(git.top_dir) + "\t")
            logfile.write(os.path.relpath(path_abs, git.top_dir) + "\n")

        try:
            chmod_readonly(store_raw)
        except OSError as err:
            perr("warning: could not remove write permissions on '" + store_raw + "'")
            perr(err.strerror)

        # at this point everything worked for path and it has been
        # turned into a symlink. Now stage this file.
        ret = subprocess.call(["git", "add", path])
        if ret != 0:
            perr("error: failed to stage '" + path + "'")
            return ret

    return 0

@command
def revert(git, args):
    """Replace symlink with a copy of its content

    usage: git raw revert <raw-link>...

    Replaces the symlinks given as arguments with their actual
    content. This is useful mainly when content needs to be appended to
    a raw file. If you need to overwrite the content, use 'git raw
    unlock' instead.
    """
    if not git.is_initialized():
        raise NotRawInitializedError()

    if len(args) == 0:
        perr("error: no files specified")
        perr(get_usage(revert))
        return 1

    paths = args

    # check that every path is a symlink to a store
    for path in paths:
        if not git.path_is_raw_link(path):
            perr("error: '" + path + "' is not a raw file")
            return 1

        if not os.path.exists(path):
            perr("error: cannot revert '" + path + "'")
            perr("'" + os.readlink(path) + "' does not exist")
            return 1

    for path in paths:
        target = os.path.join(os.path.dirname(path), os.readlink(path))

        if not os.path.exists(path):
            perr("error: cannot revert '" + path + "'")
            perr("'" + target + "' does not exist")
            return 1

        os.remove(path)
        shutil.copy2(target, path)
        mode = os.stat(path)[stat.ST_MODE]
        os.chmod(path, mode | stat.S_IWUSR)

@command
def unlock(git, args):
    """Replace symlink with a dummy ordinary file

    usage: git raw unlock <raw-link>...

    This is useful when content needs to be replaced, since all raw
    content is write-protected. Each raw-link given as argument will be
    replaced with a small dummy file that can then be overwritten. Once
    new content has been created, use 'git raw add' to add the new
    content to a content store and commit the raw-link to the git
    repository.
    """
    if not git.is_initialized():
        raise NotRawInitializedError()

    if len(args) == 0:
        perr("error: no files specified")
        perr(get_usage(unlock))
        return 1

    paths = args

    for path in paths:
        if not git.path_is_raw_link(path):
            perr("error: '" + path + "' is not a raw file")
            return 1

    for path in paths:
        git_path = os.path.relpath(path, git.top_dir)
        link_target = os.readlink(path)

        os.remove(path)
        with open(path, "wb") as dummy_file:
            dummy_file.write(git.dummy_header + b"\n")

        with open(path, "a") as dummy_file:
            dummy_file.write(git_path + " --> " + link_target + "\n")

    return 0

@command
def ls(git, args):
    """List raw-links in the working directory

    usage: git raw ls [--broken] [<file>...]

    Without arguments, the 'ls' command will list all the raw-links in
    the current working directory and recursively in any
    subdirectories. Otherwise, all raw-links among the arguments and
    raw-links in the directories given as argument are listed.

    Options:

    --broken
        List only raw-links that do not point to existing files. Useful
        when you need to know which file content are missing

    See the 'fix' command for how to fix broken raw-links.
    """
    if not git.is_initialized():
        raise NotRawInitializedError()

    flag_broken = False
    paths = []
    for arg in args:
        if arg == "--broken":
            flag_broken = True
            continue

        paths.append(arg)

    for path in paths:
        if not os.path.lexists(path):
            perr("error: '" + path + "' does not exist")
            return 1

    links = git.ls_files_symlinks_in_index(paths)
    others = git.ls_files_others(paths)
    raw_links = [p for p in links + others if git.path_is_raw_link(p)]

    if flag_broken:
        raw_links = [p for p in raw_links if not os.path.exists(p)]

    for rl in raw_links:
        print(rl)

    return 0

@command
def add_store(git, args):
    """Add a content store

    usage: git raw add-store [--set-default] <store-name> <dir>
           git raw add-store --set-default <store-name>

    You need to tell git-raw where to store file content that should be
    stored outside the normal Git repository. A set of directories can
    be configured for use by git-raw using the 'add-store'
    command. These directories are called "content stores". Each content
    store is associated with a unique name to which it is referred in
    commands.

    A default content store, called the "default add store", must be
    configured for use by git-raw's 'add' command. See 'git raw help
    add' for info on how to specify a store other than the default to
    which content should be added. The first store to be configured will
    become the default add store. The default add store can be changed
    at any time using the --set-default flag

    Options:

    --set-default

        When only a <store-name> is given as argument along with the
        --set-default option, the <store-name> must name an already
        configured content store, in which case it becomes the default
        add store. Otherwise, a directory is also given and the
        <store-name> is configured as a content store and will be set to
        the default add store.
    """
    if not git.is_initialized():
        raise NotRawInitializedError()

    if len(args) < 2 or len(args) > 3:
        perr("error: wrong number of arguments")
        perr(get_usage(add_store))
        return 1

    setdefault = False
    store_name = None
    directory = None

    argcount = 0
    for arg in args:
        if arg == "--set-default":
            setdefault = True
            continue

        if argcount == 0:
            store_name = arg
            argcount += 1
        elif argcount == 1:
            directory = arg
            argcount += 1
        else:
            perr("error: wrong arguments")
            perr(get_usage(add_store))
            return 1

    if directory == None:
        # make an already configured store the default
        if store_name not in git.config["stores"] or store_name == "defaultAddStore":
            perr("error: no store named '" + store_name + "'")
            return 1

        git.config["stores"]["defaultAddStore"] = store_name
    else:
        # configure new store
        if not check_storename(store_name):
            perr("error: illegal store-name '" + store_name + "'")
            return 1

        if store_name in git.config["stores"]:
            perr("error: a store named '" + store_name + "' already exists")
            return 1

        if not os.path.isdir(directory):
            perr("error: no such directory '" + directory + "'")
            return 1

        if setdefault or len(git.config["stores"]) == 0:
            git.config["stores"]["defaultAddStore"] = store_name

        git.config["stores"][store_name] = os.path.abspath(directory)
    try:
        with open(git.config_file, "w") as cfg:
            git.config.write(cfg)
    except:
        perr("error: could not write to config file")
        return 1

    return 0

@command
def ls_stores(git, args):
    """List configured content stores

    usage: git raw ls-stores

    lists each configured store with its name and path. The default add
    store is indicated with an asterisk.
    """
    if not git.is_initialized():
        raise NotRawInitializedError()

    if len(args) > 0:
        perr("error: unknown option")
        perr(get_usage(ls_stores))
        return 1

    if len(git.config["stores"]) == 0:
        return 0

    default_store_name = git.config["stores"]["defaultAddStore"]

    for name, path in git.config["stores"].items():
        if name == "defaultAddStore":
            continue

        store_str = "  "
        if name == default_store_name:
            store_str = "* "

        store_str += name + "\t" + path
        print(store_str)

    return 0

@command
def fix(git, args):
    """Fix broken raw links

    usage: git raw fix [<path>...]

    Will look recursively at the paths given as arguments to fix any
    broken raw-links. Links that have been moved will be set correctly
    and an attempt will be made to find content in any of the configured
    content stores.

    The order in which content is searched for is currently determined
    by the order in which the stores where added using the 'add-store'
    command. To change the order, you can reorder the stores in
    git-raw's config file located in:

    .git/git-raw/config
    """
    if not git.is_initialized():
        raise NotRawInitializedError()

    paths = args

    # ensure all given paths exist
    for path in paths:
        if not os.path.lexists(path):
            perr("error: '" + path + "' does not exist")
            return 1
    if not paths:
        print("error: git raw fix expects a path") 
        return 1

    # get all broken raw links
    links = git.ls_files_symlinks_in_index(paths)
    others = git.ls_files_others(paths)
    broken_links = [p for p in links + others if git.path_is_raw_link(p) and not os.path.exists(p)]

    # make sure all links point correctly into git-raw's index directory
    dirname = os.path.dirname
    basename = os.path.basename
    required_content = []
    for sl in broken_links:
        current_target = os.readlink(sl)
        current_target_index_dir = os.path.join(dirname(sl), dirname(dirname(dirname(current_target))))
        sha1 = basename(dirname(current_target))
        target = os.path.relpath(store_mkname(git.index_dir, sha1), dirname(sl))

        if (os.path.isdir(current_target_index_dir) and
            os.path.samefile(current_target_index_dir, git.index_dir)):
            required_content.append((sl, sha1))
            continue

        try:
            os.remove(sl)
            os.symlink(target, sl)
        except OSError as err:
            perr("error: unable to fix raw link '" + sl + "'")
            perr(err.strerror)
            return 1

        if not os.path.exists(target):
            required_content.append((sl, sha1))

    # try to create entries in the index for all required content
    if len(required_content) > 0 and len(git.config["stores"]) == 0:
        perr("error: no stores have been configured")
        perr("See the 'add-store' command")
        return 1

    for sl, sha1 in required_content:
        store_path = git.find_sha1(sha1)

        if store_path == None:
            perr("could not find store content for '" + sl + "'")
            continue

        index_path = dirname(store_mkname(git.index_dir, sha1))
        target = dirname(store_mkname(store_path, sha1))

        try:
            if os.path.lexists(index_path):
                os.remove(index_path)

            os.makedirs(dirname(index_path), exist_ok=True)
            os.symlink(target, index_path)
        except OSError as err:
            perr("error: could not create symlink in index")
            perr(err.strerror)
            return 1

@command
def help(git, args):
    """Show help on git-raw subcommands

    usage: git raw help [<command>]
    """
    if len(args) == 0:
        print(gitraw_usage)
        return 0

    cmd_name = args[0]

    if cmd_name not in commands:
        perr("'" + cmd_name + "'", "is not a git-raw command")
        perr(get_usage(help))
        return 1

    cmd = commands[cmd_name]
    doc = cmd.__doc__

    if (doc == None):
        print("No help available for command '" + cmd_name + "'")
        return 0

    paragraphs = doc.split("\n\n", 2)

    summary = paragraphs[0].rstrip()
    usage = None
    rest = None

    if len(paragraphs) >= 2:
        usage = textwrap.dedent(paragraphs[1].rstrip())
    if len(paragraphs) >= 3:
        rest = textwrap.dedent(paragraphs[2].rstrip())

    print(summary)
    if usage != None:
        print()
        print(usage)
    if rest != None:
        print()
        print(rest)

    return 0

@command
def check(git, args):
    """A dummy command used during development/debugging"""
    print(git.git_dir)
    print(git.top_dir)
    print(git.gitraw_dir)
    print(git.index_dir)
    return 0


# add command names to usage string
commands_usages_short = ["    " + name + " "*(12 - len(name)) + f.__doc__.split('\n', 1)[0] for (name, f) in commands.items()]
commands_usages_short.sort()
gitraw_usage += "\n\nAvailable commands: \n" + "\n".join(commands_usages_short)
gitraw_usage += "\n\nUse 'git help <command>' to read about a specific subcommand"

def main():
    if len(sys.argv) <= 1:
        perr(gitraw_usage)
        return 1

    flag_help = False
    flag_debug = False
    cmd_name = None
    args = []
    for arg in sys.argv[1:]:
        if arg == "--help" or arg == "-h":
            flag_help = True
            continue

        if arg == "--debug":
            flag_debug = True
            continue

        if cmd_name == None:
            cmd_name = arg
            continue

        args.append(arg)

    if flag_help:
        if cmd_name in commands:
            commands["help"](None, [cmd_name])
        else:
            commands["help"](None, [])

        return 0

    try:
        if cmd_name not in commands:
            raise UnknownCommandError(sys.argv[1])

        git = Git()
        return commands[cmd_name](git, args)
    except GitRawError as err:
        perr("git-raw:", err)
        return 1
    except:
        perr("fatal error: unknown and unexpected error")
        if flag_debug:
            raise
        else:
            perr("rerun with the --debug flag to see a traceback")
            return 2

    return 0


if __name__ == "__main__":
    sys.exit(main())
