#!/usr/bin/env python3
# Copyright 2022 Huawei Cloud Computing Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import zlib
from typing import Any, Dict, List, NoReturn, Optional, Set, Tuple, Union, cast

from argparse import ArgumentParser
from pathlib import Path

from enum import Enum

# generic JSON type that avoids getter issues; proper use is being enforced by
# return types of methods and typing vars holding return values of json getters
Json = Dict[str, Any]

g_JUST: str = "just"
g_JUST_ARGS: Dict[str, str] = {}
g_ROOT: str = "/justroot"
SYSTEM_ROOT: str = os.path.abspath(os.sep)
g_WORKSPACE_ROOT: Optional[str] = None
g_SETUP_ROOT: Optional[str] = None
g_DISTDIR: List[str] = []
MARKERS: List[str] = [".git", "ROOT", "WORKSPACE"]

g_ALWAYS_FILE: bool = False

g_GIT_CHECKOUT_LOCATIONS_FILE: Optional[str] = None
g_GIT_CHECKOUT_LOCATIONS: Dict[str, str] = {}

TAKE_OVER: List[str] = [
    "bindings",
    "target_file_name",
    "rule_file_name",
    "expression_file_name",
]
ALT_DIRS: List[str] = [
    "target_root",
    "rule_root",
    "expression_root",
]

GIT_NOBODY_ENV: Dict[str, str] = {
    "GIT_AUTHOR_DATE": "1970-01-01T00:00Z",
    "GIT_AUTHOR_NAME": "Nobody",
    "GIT_AUTHOR_EMAIL": "nobody@example.org",
    "GIT_COMMITTER_DATE": "1970-01-01T00:00Z",
    "GIT_COMMITTER_NAME": "Nobody",
    "GIT_COMMITTER_EMAIL": "nobody@example.org",
    "GIT_CONFIG_GLOBAL": "/dev/null",
    "GIT_CONFIG_SYSTEM": "/dev/null",
}

KNOWN_JUST_SUBCOMMANDS: Json = {
    "version": {
        "config": False,
        "build root": False
    },
    "describe": {
        "config": True,
        "build root": False
    },
    "analyse": {
        "config": True,
        "build root": True
    },
    "build": {
        "config": True,
        "build root": True
    },
    "install": {
        "config": True,
        "build root": True
    },
    "rebuild": {
        "config": True,
        "build root": True
    },
    "install-cas": {
        "config": False,
        "build root": True
    },
    "gc": {
        "config": False,
        "build root": True
    }
}

g_CURRENT_SUBCOMMAND: Optional[str] = None

LOCATION_TYPES: List[str] = ["workspace", "home", "system"]

DEFAULT_RC_PATH: str = os.path.join(Path.home(), ".just-mrrc")
DEFAULT_BUILD_ROOT: str = os.path.join(Path.home(), ".cache/just")
DEFAULT_CHECKOUT_LOCATIONS_FILE: str = os.path.join(Path.home(),
                                                    ".just-local.json")
DEFAULT_DISTDIRS: List[str] = [os.path.join(Path.home(), ".distfiles")]
DEFAULT_CONFIG_LOCATIONS: List[Json] = [{
    "root": "workspace",
    "path": "repos.json"
}, {
    "root": "workspace",
    "path": "etc/repos.json"
}, {
    "root": "home",
    "path": ".just-repos.json"
}, {
    "root": "system",
    "path": "etc/just-repos.json"
}]


class ObjectType(Enum):
    FILE = 1
    EXEC = 2
    LINK = 3
    DIR = 4


def log(*args: str, **kwargs: Any) -> None:
    print(*args, file=sys.stderr, **kwargs)


def fail(s: str, exit_code: int = 65) -> NoReturn:
    log(f"Error: {s}")
    sys.exit(exit_code)


def try_rmtree(tree: str) -> None:
    for _ in range(10):
        try:
            shutil.rmtree(tree)
            return
        except:
            time.sleep(1.0)
    fail("Failed to remove %s" % (tree, ))


def move_to_place(src: str, dst: str) -> None:
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    try:
        os.rename(src, dst)
    except Exception as e:
        if not os.path.exists(dst):
            fail("Failed to move %s to %s: %s" % (src, dst, e))
    if not os.path.exists(dst):
        fail("%s not present after move" % (dst, ))


def run_cmd(cmd: List[str],
            *,
            env: Any = None,
            stdout: Any = subprocess.DEVNULL,
            stdin: Any = None,
            cwd: str,
            attempts: int = 1) -> None:
    attempts = max(attempts, 1)  # at least one attempt
    for _ in range(attempts):
        if subprocess.run(cmd, cwd=cwd, env=env, stdout=stdout,
                          stdin=stdin).returncode == 0:
            return
        time.sleep(1.0)
    fail("Command %s in %s failed" % (cmd, cwd))


def find_workspace_root() -> Optional[str]:
    def is_workspace_root(path: str) -> bool:
        for m in MARKERS:
            if os.path.exists(os.path.join(path, m)):
                return True
        return False

    path: str = os.getcwd()
    while True:
        if is_workspace_root(path):
            return path
        if path == SYSTEM_ROOT:
            return None
        path = os.path.dirname(path)


def read_config(configfile: str) -> Json:
    with open(configfile) as f:
        return json.load(f)


def git_root(*, upstream: Optional[str]) -> str:
    if upstream is not None and upstream in g_GIT_CHECKOUT_LOCATIONS:
        return g_GIT_CHECKOUT_LOCATIONS[upstream]
    elif upstream and os.path.isabs(upstream) and os.path.isdir(upstream):
        return upstream
    else:
        return os.path.join(g_ROOT, "repositories/generation-0/git")


def is_cache_git_root(upstream: Optional[str]) -> bool:
    """return true if upstream is the default git root in the cache directory"""
    return git_root(upstream=upstream) == git_root(upstream=None)


def git_keep(commit: str, *, upstream: Optional[str]) -> None:
    if not is_cache_git_root(upstream):
        # for those, we assume the referenced commit is kept by
        # some branch anyway
        return
    git_env = {**os.environ, **GIT_NOBODY_ENV}
    run_cmd([
        "git", "tag", "-f", "-m", "Keep referenced tree alive",
        "keep-%s" % (commit, ), commit
    ],
            cwd=git_root(upstream=upstream),
            env=git_env,
            attempts=3)


def git_init_options(*, upstream: Optional[str]) -> List[str]:
    if upstream in g_GIT_CHECKOUT_LOCATIONS:
        return []
    else:
        return ["--bare"]


def ensure_git(*, upstream: Optional[str]) -> None:
    root: str = git_root(upstream=upstream)
    if os.path.exists(root):
        return
    os.makedirs(root)
    run_cmd(["git", "init"] + git_init_options(upstream=upstream), cwd=root)


def git_commit_present(commit: str, *, upstream: str) -> bool:
    result = subprocess.run(["git", "show", "--oneline", commit],
                            stdout=subprocess.DEVNULL,
                            stderr=subprocess.DEVNULL,
                            cwd=git_root(upstream=upstream))
    return result.returncode == 0


def git_url_is_path(url: str) -> bool:
    for prefix in ["ssh://", "http://", "https://"]:
        if url.startswith(prefix):
            return False
    return True


def git_fetch(*, repo: str, branch: str) -> None:
    if git_url_is_path(repo):
        repo = os.path.abspath(repo)
    run_cmd(["git", "fetch", repo, branch], cwd=git_root(upstream=repo))


def subdir_path(checkout: str, desc: Json) -> str:
    return os.path.normpath(
        os.path.join(checkout, cast(str, desc.get("subdir", "."))))


def git_tree(*, commit: str, subdir: str, upstream: str) -> str:
    tree = subprocess.run(
        ["git", "log", "-n", "1", "--format=%T", commit],
        stdout=subprocess.PIPE,
        cwd=git_root(upstream=upstream)).stdout.decode('utf-8').strip()
    return git_subtree(tree=tree, subdir=subdir, upstream=upstream)


def git_subtree(*, tree: str, subdir: str, upstream: Optional[str]) -> str:
    if subdir == ".":
        return tree
    return subprocess.Popen(
        ["git", "cat-file", "--batch-check=%(objectname)"],
        stdout=subprocess.PIPE,
        stdin=subprocess.PIPE,
        cwd=git_root(upstream=upstream)).communicate(
            input=("%s:%s" %
                   (tree, subdir)).encode())[0].decode('utf-8').strip()


def git_checkout_dir(commit: str) -> str:
    return os.path.join(g_ROOT, "workspaces", "git", commit)


def git_checkout(desc: Json) -> List[str]:
    commit: str = desc["commit"]
    target: str = git_checkout_dir(commit)
    if g_ALWAYS_FILE and os.path.exists(target):
        return ["file", subdir_path(target, desc)]
    repo: str = desc["repository"]
    if git_url_is_path(repo):
        repo = os.path.abspath(repo)
    root: str = git_root(upstream=repo)
    ensure_git(upstream=repo)
    if not git_commit_present(commit, upstream=repo):
        branch: str = desc["branch"]
        log("Fetching %s from %s (in %s)" % (branch, repo, root))
        git_fetch(repo=repo, branch=branch)
        if not git_commit_present(commit, upstream=repo):
            fail("Fetching %s from %s failed to fetch %s" %
                 (branch, repo, commit))
        git_keep(commit, upstream=repo)
    if g_ALWAYS_FILE:
        if os.path.exists(target):
            try_rmtree(target)
        os.makedirs(target)
        with tempfile.TemporaryFile() as f:
            run_cmd(["git", "archive", commit], cwd=root, stdout=f)
            f.seek(0)
            run_cmd(["tar", "x"], cwd=target, stdin=f)
            return ["file", subdir_path(target, desc)]
    tree: str = git_tree(commit=commit,
                         subdir=desc.get("subdir", "."),
                         upstream=repo)
    return ["git tree", tree, root]


def update_git(desc: Json) -> None:
    repo: str = desc["repository"]
    branch: str = desc["branch"]
    lsremote = subprocess.run(["git", "ls-remote", repo, branch],
                              stdout=subprocess.PIPE).stdout
    desc["commit"] = lsremote.decode('utf-8').split('\t')[0]


def git_hash(content: bytes, type: str = "blob") -> Tuple[str, bytes]:
    # Returns the git hash of the object, as well as the header to be stored
    header = "{} {}\0".format(type, len(content)).encode('utf-8')
    h = hashlib.sha1()
    h.update(header)
    h.update(content)
    return h.hexdigest(), header


def add_to_cas(data: Union[str, bytes]) -> str:
    if isinstance(data, str):
        data = data.encode('utf-8')
    h, _ = git_hash(data)
    cas_root = os.path.join(
        g_ROOT, f"protocol-dependent/generation-0/git-sha1/casf/{h[0:2]}")
    basename = h[2:]
    target = os.path.join(cas_root, basename)
    tempname = os.path.join(cas_root, "%s.%d" % (basename, os.getpid()))

    if os.path.exists(target):
        return target

    os.makedirs(cas_root, exist_ok=True)
    with open(tempname, "wb") as f:
        f.write(data)
        f.flush()
        os.chmod(f.fileno(), 0o444)
        os.fsync(f.fileno())
    os.utime(tempname, (0, 0))
    os.rename(tempname, target)
    return target


def cas_path(h: str) -> str:
    return os.path.join(
        g_ROOT, f"protocol-dependent/generation-0/git-sha1/casf/{h[0:2]}",
        h[2:])


def is_in_cas(h: str) -> bool:
    return os.path.exists(cas_path(h))


def add_file_to_cas(filename: str) -> None:
    # TODO: avoid going through memory
    with open(filename, "rb") as f:
        data = f.read()
        add_to_cas(data)


def add_distfile_to_cas(distfile: str) -> None:
    for d in g_DISTDIR:
        candidate = os.path.join(d, distfile)
        if os.path.exists(candidate):
            add_file_to_cas(candidate)


def archive_checkout_dir(content: str, repo_type: str) -> str:
    return os.path.join(g_ROOT, "workspaces", repo_type, content)


def archive_tmp_checkout_dir(content: str, repo_type: str) -> str:
    return os.path.join(g_ROOT, "tmp-workspaces", repo_type,
                        "%d-%s" % (os.getpid(), content))


def archive_tree_id_file(content: str, repo_type: str) -> str:
    return os.path.join(g_ROOT, "repositories/generation-0/tree-map", repo_type,
                        content)


def get_distfile(desc: Json) -> str:
    distfile = desc.get("distfile")
    if not distfile:
        distfile = os.path.basename(cast(str, desc.get("fetch")))
    return distfile


def archive_fetch(desc: Json, content: str) -> None:
    """ Makes sure archive is available and accounted for in cas
    """
    if not is_in_cas(content):
        distfile = get_distfile(desc)
        if distfile:
            add_distfile_to_cas(distfile)
    if not is_in_cas(content):
        url: str = desc["fetch"]
        data = subprocess.run(["wget", "-O", "-", url],
                              stdout=subprocess.PIPE).stdout
        if "sha256" in desc:
            actual_hash = hashlib.sha256(data).hexdigest()
            if desc["sha256"] != actual_hash:
                fail("SHA256 mismatch for %s, expected %s, found %s" %
                     (url, desc["sha256"], actual_hash))
        if "sha512" in desc:
            actual_hash = hashlib.sha512(data).hexdigest()
            if desc["sha512"] != actual_hash:
                fail("SHA512 mismatch for %s, expected %s, found %s" %
                     (url, desc["sha512"], actual_hash))
        add_to_cas(data)
        if not is_in_cas(content):
            fail("Failed to fetch a file with id %s from %s" % (content, url))


def archive_checkout(desc: Json, repo_type: str = "archive") -> List[str]:
    content_id: str = desc["content"]
    target: str = archive_checkout_dir(content_id, repo_type=repo_type)
    if g_ALWAYS_FILE and os.path.exists(target):
        return ["file", subdir_path(target, desc)]
    tree_id_file: str = archive_tree_id_file(content_id, repo_type=repo_type)
    if (not g_ALWAYS_FILE) and os.path.exists(tree_id_file):
        with open(tree_id_file) as f:
            archive_tree_id = f.read()
        # ensure git cache exists
        ensure_git(upstream=None)
        return [
            "git tree",
            git_subtree(tree=archive_tree_id,
                        subdir=desc.get("subdir", "."),
                        upstream=None),
            git_root(upstream=None),
        ]
    archive_fetch(desc, content=content_id)
    target_tmp: str = archive_tmp_checkout_dir(content_id, repo_type=repo_type)
    if os.path.exists(target_tmp):
        try_rmtree(target_tmp)
    os.makedirs(target_tmp)
    if repo_type == "zip":
        try:
            run_cmd(["unzip", "-d", ".", cas_path(content_id)], cwd=target_tmp)
        except:
            try:
                run_cmd(["7z", "x", cas_path(content_id)], cwd=target_tmp)
            except:
                print("Failed to extract zip-like archive %s" %
                      (cas_path(content_id), ))
                sys.exit(1)
    else:
        try:
            run_cmd(["tar", "xf", cas_path(content_id)], cwd=target_tmp)
        except:
            print("Failed to extract tarball %s" % (cas_path(content_id), ))
            sys.exit(1)
    if g_ALWAYS_FILE:
        move_to_place(target_tmp, target)
        return ["file", subdir_path(target, desc)]
    tree: str = import_to_git(target_tmp, repo_type, content_id)
    try_rmtree(target_tmp)
    os.makedirs(os.path.dirname(tree_id_file), exist_ok=True)
    with open(tree_id_file, "w") as f:
        f.write(tree)
    return [
        "git tree",
        git_subtree(tree=tree, subdir=desc.get("subdir", "."), upstream=None),
        git_root(upstream=None)
    ]


def import_tmp_dir(content: str) -> str:
    return os.path.join(g_ROOT, "tmp-workspaces", "import",
                        "%d-%s" % (os.getpid(), content))


def type_to_perm(obj_type: ObjectType) -> str:
    if obj_type == ObjectType.DIR:
        return "40000"
    elif obj_type == ObjectType.LINK:
        return "120000"
    elif obj_type == ObjectType.EXEC:
        return "100755"
    else:  # obj_type == ObjectType.FILE
        return "100644"


def write_blob_to_repo(repo_root: str, data: bytes) -> bytes:
    # Get blob hash and header to be stored
    h, header = git_hash(data, type="blob")

    # Write repository object
    obj_dir = "{}/.git/objects/{}".format(repo_root, h[0:2])
    obj_file = "{}/{}".format(obj_dir, h[2:])
    os.makedirs(obj_dir, exist_ok=True)
    with open(obj_file, "wb") as f:
        f.write(zlib.compress(header + data))

    return bytes.fromhex(h)  # raw id


def write_tree_to_repo(repo_root: str,
                       entries: Dict[str, Tuple[bytes, ObjectType]]) -> bytes:
    # Tree entries have as key their filename and as value a tuple of raw id and
    # object type. They must be sorted by filename.
    tree_content: bytes = b""
    for fname, entry in sorted(entries.items()):
        if entry[1] == ObjectType.DIR:
            fname = fname[:-1]  # remove trailing '/'
        tree_content += "{} {}\0".format(type_to_perm(entry[1]),
                                         fname).encode('utf-8') + entry[0]

    # Get tree hash and header to be stored
    h, header = git_hash(tree_content, type="tree")

    # Write repository object
    obj_dir = "{}/.git/objects/{}".format(repo_root, h[0:2])
    obj_file = "{}/{}".format(obj_dir, h[2:])
    os.makedirs(obj_dir, exist_ok=True)
    with open(obj_file, "wb") as f:
        f.write(zlib.compress(header + tree_content))

    return bytes.fromhex(h)  # raw id


def path_to_type(fpath: str) -> ObjectType:
    if os.path.islink(fpath):
        return ObjectType.LINK
    elif os.path.isdir(fpath):
        return ObjectType.DIR
    else:
        if os.access(fpath, os.X_OK):
            return ObjectType.EXEC
        else:
            return ObjectType.FILE


def get_tree_raw_id(source_dir: str, repo_root: str) -> bytes:
    # Writes the content of the directory recursively to the repository and
    # returns its sha1 hash and its raw bytes representation
    entries: Dict[str, Tuple[bytes, ObjectType]] = {}
    for fname in os.listdir(source_dir):
        fpath = source_dir + "/" + fname
        obj_type = path_to_type(fpath)
        raw_h: bytes = b""
        if obj_type == ObjectType.DIR:
            raw_h = get_tree_raw_id(fpath, repo_root)
            fname = fname + '/'  # trailing '/' added for correct sorting
        elif obj_type == ObjectType.LINK:
            data = os.readlink(fpath).encode('utf-8')
            raw_h = write_blob_to_repo(repo_root, data)
        else:
            with open(fpath, "rb") as f:
                data = f.read()
                raw_h = write_blob_to_repo(repo_root, data)
        # Add entry to map
        entries[fname] = (raw_h, obj_type)

    return write_tree_to_repo(repo_root, entries)


def import_to_git(target: str, repo_type: str, content_id: str) -> str:
    # In order to import content that might otherwise be ignored by Git, such
    # as empty directories or magic-named files and folders (e.g., .git,
    # .gitignore), add entries manually to the repository, which should be in
    # its own separate location
    repo_tmp_dir = import_tmp_dir(content_id)
    if os.path.exists(repo_tmp_dir):
        try_rmtree(repo_tmp_dir)
    os.makedirs(repo_tmp_dir)

    # Initialize repo to have access to its storage
    git_env = {**os.environ, **GIT_NOBODY_ENV}
    run_cmd(
        ["git", "init"],
        cwd=repo_tmp_dir,
        env=git_env,
    )

    # Get tree id of added directory
    tree_id: str = get_tree_raw_id(target, repo_tmp_dir).hex()

    # Commit the tree
    commit: str = subprocess.run(
        [
            "git", "commit-tree", tree_id, "-m",
            "Content of %s %r" % (repo_type, content_id)
        ],
        stdout=subprocess.PIPE,
        cwd=repo_tmp_dir,
        env=git_env,
    ).stdout.decode('utf-8').strip()

    # Update the HEAD to make the tree fetchable
    run_cmd(
        ["git", "update-ref", "HEAD", commit],
        cwd=repo_tmp_dir,
        env=git_env,
    )

    # Fetch commit into Git cache repository and tag it
    ensure_git(upstream=None)
    run_cmd(["git", "fetch", repo_tmp_dir], cwd=git_root(upstream=None))
    git_keep(commit, upstream=None)
    return tree_id


def file_as_git(fpath: str) -> List[str]:
    root_result = subprocess.run(["git", "rev-parse", "--show-toplevel"],
                                 cwd=fpath,
                                 stdout=subprocess.PIPE,
                                 stderr=subprocess.DEVNULL)
    if not root_result.returncode == 0:
        # TODO: consider also doing this for pending changes
        # copy non-git paths to tmp-workspace and import to git
        fpath = os.path.realpath(fpath)
        if g_CURRENT_SUBCOMMAND:
            log(f"""\
Warning: Inefficient Git import of file path '{fpath}'.
         Please consider using 'just-mr setup' and 'just {g_CURRENT_SUBCOMMAND}'
         separately to cache the output.""")
        target = archive_tmp_checkout_dir(os.path.relpath(fpath, "/"),
                                          repo_type="file")
        os.makedirs(os.path.dirname(target), exist_ok=True)
        shutil.copytree(fpath, target)
        tree = import_to_git(target, "file", fpath)
        try_rmtree(target)
        return ["git tree", tree, git_root(upstream=None)]
    root = root_result.stdout.decode('utf-8').rstrip()
    subdir = os.path.relpath(fpath, root)
    root_tree = subprocess.run(
        ["git", "log", "-n", "1", "--format=%T"],
        cwd=root,
        stdout=subprocess.PIPE).stdout.decode('utf-8').strip()
    return [
        "git tree",
        git_subtree(tree=root_tree, subdir=subdir, upstream=root), root
    ]


def file_checkout(desc: Json) -> List[str]:
    fpath = os.path.abspath(desc["path"])
    if desc.get("pragma", {}).get("to_git") and not g_ALWAYS_FILE:
        return file_as_git(fpath)
    return ["file", os.path.abspath(fpath)]


# strongly imposing typing on this method is complicated, so resort to Any
def resolve_repo(desc: Any,
                 *,
                 seen: Optional[List[str]] = None,
                 repos: Json) -> Json:
    seen = seen or []
    if not isinstance(desc, str):
        return desc
    if desc in seen:
        fail("Cyclic reference in repository source definition: %r" % (seen, ))
    return resolve_repo(repos[desc]["repository"],
                        seen=seen + [desc],
                        repos=repos)


def distdir_repo_dir(content: str) -> str:
    return os.path.join(g_ROOT, "distdir", content)


def distdir_tmp_dir(content: str) -> str:
    return os.path.join(g_ROOT, "tmp-workspaces", "distdir",
                        "%d-%s" % (os.getpid(), content))


def distdir_tree_id_file(content: str) -> str:
    return os.path.join(g_ROOT, "repositories/generation-0/distfiles-tree-map",
                        content)


def distdir_checkout(desc: Json, repos: Json) -> List[str]:
    """ Logic for processing the distdir repo type.
    """
    content: Dict[str, str] = {}

    # All repos in distdir list are considered, irrespective of reachability
    distdir_repos: List[str] = desc.get("repositories", [])
    for repo in distdir_repos:
        # If repo does not exist, fail
        if repo not in repos:
            print("No configuration for repository %s found" % (repo, ))
            sys.exit(1)
        repo_desc: Json = repos[repo].get("repository", {})
        repo_desc_type = repo_desc.get("type")
        # Only do work for archived types
        if repo_desc_type in ["archive", "zip"]:
            # fetch repo
            content_id: str = repo_desc["content"]
            # Store the relevant info in the map
            content[get_distfile(repo_desc)] = content_id

    # Hash the map as unique id for the distdir repo entry
    distdir_content_id, _ = git_hash(
        json.dumps(content, sort_keys=True,
                   separators=(',', ':')).encode('utf-8'))
    target_distdir_dir = distdir_repo_dir(distdir_content_id)

    # Check if content already exists
    if g_ALWAYS_FILE and os.path.exists(target_distdir_dir):
        return ["file", target_distdir_dir]
    tree_id_file = distdir_tree_id_file(distdir_content_id)
    if (not g_ALWAYS_FILE) and os.path.exists(tree_id_file):
        with open(tree_id_file) as f:
            distdir_tree_id = f.read()
        # ensure git cache exists
        ensure_git(upstream=None)
        return [
            "git tree",
            git_subtree(tree=distdir_tree_id, subdir=".", upstream=None),
            git_root(upstream=None)
        ]

    # As the content is not there already, so we have to ensure the archives
    # are present.
    for repo in distdir_repos:
        repo_desc = repos[repo].get("repository", {})
        repo_desc_type = repo_desc.get("type")
        if repo_desc_type in ["archive", "zip"]:
            content_id = repo_desc["content"]
            archive_fetch(repo_desc, content=content_id)

    # Create the dirstdir repo folder content
    target_tmp_dir = distdir_tmp_dir(distdir_content_id)
    if os.path.exists(target_tmp_dir):
        try_rmtree(target_tmp_dir)
    os.makedirs(target_tmp_dir)
    for name, content_id in content.items():
        target = os.path.join(target_tmp_dir, name)
        os.link(cas_path(content_id), target)

    if g_ALWAYS_FILE:
        # Return with path to distdir repo
        move_to_place(target_tmp_dir, target_distdir_dir)
        return ["file", target_distdir_dir]

    # Gitify the distdir repo folder
    tree = import_to_git(target_tmp_dir, "distdir", distdir_content_id)
    try_rmtree(target_tmp_dir)

    # Cache git info to tree id file
    os.makedirs(os.path.dirname(tree_id_file), exist_ok=True)
    with open(tree_id_file, "w") as f:
        f.write(tree)

    # Return git tree info
    return [
        "git tree",
        git_subtree(tree=tree, subdir=".", upstream=None),
        git_root(upstream=None)
    ]


def checkout(desc: Json, *, name: str, repos: Json) -> List[str]:
    repo_desc = resolve_repo(desc, repos=repos)
    repo_type = repo_desc.get("type")
    if repo_type == "git":
        return git_checkout(repo_desc)
    if repo_type in ["archive", "zip"]:
        return archive_checkout(repo_desc, repo_type=cast(str, repo_type))
    if repo_type == "file":
        return file_checkout(repo_desc)
    if repo_type == "distdir":
        return distdir_checkout(repo_desc, repos=repos)
    fail("Unknown repository type %s for %s" % (repo_type, name))


def reachable_repositories(repo: str, *,
                           repos: Json) -> Tuple[Set[str], Set[str]]:
    # First compute the set of repositories transitively reachable via bindings
    reachable: Set[str] = set()

    def traverse(x: str) -> None:
        nonlocal reachable
        if x in reachable:
            return
        reachable.add(x)
        bindings = repos[x].get("bindings", {})
        for bound in bindings.values():
            traverse(bound)

    traverse(repo)

    # Now add the repositories that serve as overlay directories for
    # targets, rules, etc. Those repositories have to be fetched as well, but
    # we do not have to consider their bindings.
    to_fetch = reachable.copy()
    for x in reachable:
        for layer in ALT_DIRS:
            if layer in repos[x]:
                to_fetch.add(repos[x][layer])

    return reachable, to_fetch


def setup(*,
          config: Json,
          main: Optional[str],
          setup_all: bool = False,
          interactive: bool = False) -> str:
    cwd: str = os.getcwd()
    if g_SETUP_ROOT:
        os.chdir(g_SETUP_ROOT)
    repos: Json = config.get("repositories", {})
    repos_to_setup = repos.keys()
    repos_to_include = repos.keys()
    mr_config: Json = {}

    if main is None:
        main = config.setdefault("main", None)
        if not isinstance(main, str) and main is not None:
            fail("Unsupported value for field 'main' in configuration.")

    if main is not None:
        # pass on main that was explicitly set via command line or config
        mr_config["main"] = main

    if main is None and len(repos_to_setup) > 0:
        main = sorted(list(repos_to_setup))[0]

    if main is not None and not setup_all:
        repos_to_include, repos_to_setup = reachable_repositories(main,
                                                                  repos=repos)

    mr_repos: Json = {}
    for repo in repos_to_setup:
        desc = repos[repo]
        if repo == main and interactive:
            config = {}
        else:
            workspace = checkout(desc.get("repository", {}),
                                 name=repo,
                                 repos=repos)
            config = {"workspace_root": workspace}
        for key in TAKE_OVER:
            val = desc.get(key, {})
            if val:
                config[key] = val
        mr_repos[repo] = config
    # Alternate directories are specifies as the workspace of
    # some other repository. So we have to iterate over all repositories again
    # to add those directories. We do this only for the repositories we include
    # in the final configuration.
    for repo in repos_to_include:
        desc = repos[repo]
        if repo == main and interactive:
            continue
        for key in ALT_DIRS:
            val = desc.get(key, {})
            if val:
                if val == main and interactive:
                    continue
                mr_repos[repo][key] = mr_repos[val]["workspace_root"]
    mr_repos_actual: Json = {}
    for repo in repos_to_include:
        mr_repos_actual[repo] = mr_repos[repo]
    mr_config["repositories"] = mr_repos_actual

    if g_SETUP_ROOT:
        os.chdir(cwd)

    return add_to_cas(json.dumps(mr_config, indent=2, sort_keys=True))


def call_just(*, config: Json, main: Optional[str], args: List[str]) -> None:
    subcommand = args[0] if len(args) > 0 else None
    args = args[1:]
    use_config: bool = False
    use_build_root: bool = False
    setup_config = config  # default to given config path
    if subcommand and subcommand in KNOWN_JUST_SUBCOMMANDS:
        if KNOWN_JUST_SUBCOMMANDS[subcommand]["config"]:
            use_config = True
            global g_CURRENT_SUBCOMMAND
            g_CURRENT_SUBCOMMAND = subcommand
            setup_config = setup(config=config, main=main)
            g_CURRENT_SUBCOMMAND = None
        use_build_root = KNOWN_JUST_SUBCOMMANDS[subcommand]["build root"]
    if not setup_config:
        fail("Setup failed")
    cmd: List[str] = [g_JUST]
    cmd += [subcommand] if subcommand else []
    cmd += ["-C", cast(str, setup_config)] if use_config else []
    cmd += ["--local-build-root", g_ROOT] if use_build_root else []
    if subcommand and subcommand in g_JUST_ARGS:
        cmd += g_JUST_ARGS[subcommand]
    cmd += args
    log("Setup finished, exec %s" % (cmd, ))
    try:
        os.execvp(g_JUST, cmd)
    except Exception as e:
        log(str(e))
    finally:
        fail(f"exec failed", 64)


def update(*, config: Json, repos: Json) -> None:
    for repo in repos:
        desc: Json = config["repositories"][repo]["repository"]
        desc = resolve_repo(desc, repos=config["repositories"])
        repo_type = desc.get("type")
        if repo_type == "git":
            update_git(desc)
        else:
            fail("Don't know how to update %s repositories" % (repo_type, ))
    print(json.dumps(config, indent=2))
    sys.exit(0)


def fetch(*,
          config: Json,
          fetch_dir: Optional[str],
          main: Optional[str],
          fetch_all: bool = False) -> None:
    if not fetch_dir:
        for d in g_DISTDIR:
            if os.path.isdir(d):
                fetch_dir = os.path.abspath(d)
                break
    if not fetch_dir:
        fail("No directory found to fetch to, considered %r" % (g_DISTDIR, ))

    repos = config["repositories"]
    repos_to_fetch = repos.keys()

    if not fetch_all:
        if main is None and len(repos_to_fetch) > 0:
            main = sorted(list(repos_to_fetch))[0]
        if main is not None:
            repos_to_fetch = reachable_repositories(main, repos=repos)[0]

        def is_subpath(path: str, base: str) -> bool:
            return os.path.commonpath([path, base]) == base

        # warn if fetch_dir is in invocation workspace
        if g_WORKSPACE_ROOT and is_subpath(fetch_dir, g_WORKSPACE_ROOT):
            repo = repos.get(main, {}).get("repository", {})
            repo_path = repo.get("path", None)
            if repo_path is not None and repo.get("type", None) == "file":
                if not os.path.isabs(repo_path):
                    repo_path = os.path.realpath(
                        os.path.join(cast(str, g_SETUP_ROOT), repo_path))
                # only warn if repo workspace differs to invocation workspace
                if not is_subpath(repo_path, g_WORKSPACE_ROOT):
                    log(f"""\
Warning: Writing distribution files to workspace location '{fetch_dir}',
         which is different to the workspace of the requested main repository
         '{repo_path}'.""")

    print("Fetching to %r" % (fetch_dir, ))

    for repo in repos_to_fetch:
        desc = repos[repo]
        if ("repository" in desc and isinstance(desc["repository"], dict)
                and desc["repository"]["type"] in ["zip", "archive"]):
            repo_desc = desc["repository"]
            distfile = repo_desc.get("distfile") or os.path.basename(
                repo_desc["fetch"])
            content = repo_desc["content"]
            print("%r --> %r (content: %s)" % (repo, distfile, content))
            archive_fetch(repo_desc, content)
            shutil.copyfile(cas_path(content),
                            os.path.join(fetch_dir, distfile))

    sys.exit(0)


def read_location(location: Json) -> Optional[Tuple[str, str]]:
    root = location.setdefault("root", None)
    path = location.setdefault("path", None)
    base = location.setdefault("base", ".")

    if not path or root not in LOCATION_TYPES:
        fail(f"malformed location object: {location}")

    if root == "workspace":
        if not g_WORKSPACE_ROOT:
            log(f"Warning: Not in workspace root, ignoring location %s." %
                (location, ))
            return None
        root = g_WORKSPACE_ROOT
    if root == "home":
        root = Path.home()
    if root == "system":
        root = SYSTEM_ROOT

    return os.path.realpath(os.path.join(cast(str, root), cast(
        str, path))), os.path.realpath(os.path.join(cast(str, root), base))


# read settings from just-mrrc and return config path
def read_justmrrc(rcpath: str, no_rc: bool = False) -> Optional[str]:
    rc: Json = {}
    if not no_rc:
        if not rcpath:
            rcpath = DEFAULT_RC_PATH
        elif not os.path.isfile(rcpath):
            fail(f"cannot read rc file {rcpath}.")
        if os.path.isfile(rcpath):
            with open(rcpath) as f:
                rc = json.load(f)

    location: Optional[Json] = rc.get("local build root", None)
    build_root = read_location(location) if location else None
    if build_root:
        global g_ROOT
        g_ROOT = build_root[0]

    location = rc.get("checkout locations", None)
    checkout = read_location(location) if location else None
    if checkout:
        global g_GIT_CHECKOUT_LOCATIONS_FILE
        if not os.path.isfile(checkout[0]):
            fail(f"cannot find checkout locations files {checkout[0]}.")
        g_GIT_CHECKOUT_LOCATIONS_FILE = checkout[0]

    location = rc.get("distdirs", None)
    if location:
        global g_DISTDIR
        g_DISTDIR = []
        for l in location:
            paths = read_location(cast(Json, l))
            if paths:
                if os.path.isdir(paths[0]):
                    g_DISTDIR.append(paths[0])
                else:
                    log(f"Warning: Ignoring non-existing distdir {paths[0]}.")

    location = rc.get("just", None)
    just = read_location(location) if location else None
    if just:
        global g_JUST
        g_JUST = just[0]

    global g_JUST_ARGS
    g_JUST_ARGS = rc.get("just args", {})

    for location in rc.get("config lookup order", DEFAULT_CONFIG_LOCATIONS):
        paths = read_location(cast(Json, location))
        if paths and os.path.isfile(paths[0]):
            global g_SETUP_ROOT
            g_SETUP_ROOT = paths[1]
            return paths[0]

    return None


def main():
    parser = ArgumentParser()
    parser.add_argument("-C",
                        dest="repository_config",
                        help="Repository-description file to use",
                        metavar="FILE")
    parser.add_argument("--checkout-locations",
                        dest="checkout_location",
                        help="Specification file for checkout locations")
    parser.add_argument("--local-build-root",
                        dest="local_build_root",
                        help="Root for CAS, repository space, etc",
                        metavar="PATH")
    parser.add_argument("--distdir",
                        dest="distdir",
                        action="append",
                        default=[],
                        help="Directory to look for distfiles before fetching",
                        metavar="PATH")
    parser.add_argument("--just",
                        dest="just",
                        help="Path to the just binary",
                        metavar="PATH")
    parser.add_argument("--always-file",
                        dest="always_file",
                        action="store_true",
                        default=False,
                        help="Always create file roots")
    parser.add_argument(
        "--main",
        dest="main",
        default=None,
        help="Main repository to consider from the configuration.")
    parser.add_argument("--rc",
                        dest="rcfile",
                        help="Use just-mrrc file from custom path.")
    parser.add_argument("--norc",
                        dest="norc",
                        action="store_true",
                        default=False,
                        help="Do not use any just-mrrc file.")
    subcommands = parser.add_subparsers(dest="subcommand",
                                        title="subcommands",
                                        required=True)

    repo_parser = ArgumentParser(add_help=False)
    repo_parser.add_argument(
        "--all",
        dest="sub_all",
        action="store_true",
        default=False,
        help="Consider all repositories in the configuration.")
    repo_parser.add_argument(
        "sub_main",
        metavar="main-repo",
        nargs="?",
        default=None,
        help="Main repository to consider from the configuration.")

    subcommands.add_parser("setup",
                           parents=[repo_parser],
                           help="Setup and generate just configuration.")

    subcommands.add_parser(
        "setup-env",
        parents=[repo_parser],
        help="Setup without workspace root for the main repository.")

    fetch_parser = subcommands.add_parser(
        "fetch",
        parents=[repo_parser],
        help="Fetch and store distribution files.")
    fetch_parser.add_argument("-o",
                              dest="fetch_dir",
                              help="Directory to write distfiles when fetching",
                              metavar="PATH")

    update_parser = subcommands.add_parser(
        "update",
        help="Advance Git commit IDs and print updated just-mr configuration.")
    update_parser.add_argument("update_repos",
                               metavar="repo",
                               nargs="*",
                               default=[],
                               help="Repository to update.")

    subcommands.add_parser("do",
                           help="Canonical way of specifying just subcommands",
                           add_help=False)

    for cmd in KNOWN_JUST_SUBCOMMANDS:
        subcommands.add_parser(cmd,
                               help=f"Run setup and call 'just {cmd}'",
                               add_help=False)

    (options, args) = parser.parse_known_args()

    global g_ROOT, g_GIT_CHECKOUT_LOCATIONS_FILE, g_DISTDIR, g_SETUP_ROOT, g_WORKSPACE_ROOT
    g_ROOT = DEFAULT_BUILD_ROOT
    if os.path.isfile(DEFAULT_CHECKOUT_LOCATIONS_FILE):
        g_GIT_CHECKOUT_LOCATIONS_FILE = DEFAULT_CHECKOUT_LOCATIONS_FILE
    g_DISTDIR = DEFAULT_DISTDIRS
    g_SETUP_ROOT = os.path.curdir
    g_WORKSPACE_ROOT = find_workspace_root()

    config_file = read_justmrrc(options.rcfile, options.norc)
    if options.repository_config:
        config_file = options.repository_config

    if not config_file:
        fail("cannot find repository configuration.")
    config = read_config(cast(str, config_file))

    if options.local_build_root:
        g_ROOT = os.path.abspath(options.local_build_root)

    if options.checkout_location:
        if not os.path.isfile(options.checkout_location):
            fail("cannot find checkout locations file %s" %
                 (options.checkout_location, ))
        g_GIT_CHECKOUT_LOCATIONS_FILE = os.path.abspath(
            options.checkout_location)

    if g_GIT_CHECKOUT_LOCATIONS_FILE:
        with open(g_GIT_CHECKOUT_LOCATIONS_FILE) as f:
            global g_GIT_CHECKOUT_LOCATIONS
            g_GIT_CHECKOUT_LOCATIONS = json.load(f).get("checkouts",
                                                        {}).get("git", {})

    if options.distdir:
        g_DISTDIR += options.distdir

    global g_JUST
    if options.just:
        g_JUST = options.just
    global g_ALWAYS_FILE
    g_ALWAYS_FILE = options.always_file

    sub_main = options.sub_main if hasattr(options, "sub_main") else None
    sub_all = options.sub_all if hasattr(options, "sub_all") else None
    if not sub_all and options.main and sub_main and options.main != sub_main:
        print(
            "Warning: conflicting options for main repository, selecting '%s'."
            % (sub_main, ))
    main: Optional[str] = sub_main or options.main

    if options.subcommand in KNOWN_JUST_SUBCOMMANDS:
        call_just(config=config, main=main, args=[options.subcommand] + args)
        return
    if options.subcommand == "do":
        call_just(config=config, main=main, args=args)
        return

    if args:
        print("Warning: ignoring unknown arguments %r" % (args, ))

    if options.subcommand == "setup-env":
        # Setup for interactive use, i.e., fetch the required repositories
        # and generate an appropriate multi-repository configuration file.
        # Store it in the CAS and print its path on stdout.
        #
        # For the main repository (if specified), leave out the workspace
        # so that in the usage of just the workspace is determined from
        # the working directory; in this way, working on a checkout of that
        # repository is possible, while having all dependencies set up
        # correctly.
        print(
            setup(config=config,
                  main=main,
                  setup_all=options.sub_all,
                  interactive=True))
        return
    if options.subcommand == "setup":
        # Setup such that the main repository keeps its workspace given in
        # the input config.
        print(setup(config=config, main=main, setup_all=options.sub_all))
        return
    if options.subcommand == "update":
        update(config=config, repos=options.update_repos)
    if options.subcommand == "fetch":
        fetch(config=config,
              fetch_dir=options.fetch_dir,
              main=main,
              fetch_all=options.sub_all)
    fail("Unknown subcommand %s" % (options.subcommand, ))


if __name__ == "__main__":
    main()
