#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright © 2015-2017 Collabora Ltd.
#
# SPDX-License-Identifier: MPL-2.0
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

# deb-build-snapshot — Build snapshots of a package.
#
# Assumptions:
# * deb-git-version-gen is next to this script's physical location,
#   or elsewhere in PATH
# * The current working directory is the root of a git repository
# * The current branch has upstream sources and a debian/ directory
# * Debian packaging tags look like debian/1.2.3-4 (or vendor/1.2.3-4vendor5)
# * mktemp uses a location with plenty of space
#
# and with --upstream or --auto-upstream:
# * Upstream tags look like v1.2.3 or 1.2.3
# * either:
#   - The upstream project uses Autotools
#   - "NOCONFIGURE=1 ./autogen.sh" and "./configure" lead to a reasonable build
# * or:
#   - The upstream project uses Meson
#   - ninja dist leads to a reasonable build
# * Parallel builds (including Autotools distcheck) work

import abc
import argparse
import contextlib
import enum
import fnmatch
import glob
import json
import logging
import os
import shlex
import subprocess
import sys

try:
    import typing
except ImportError:
    pass
else:
    typing  # silence "unused" warnings

# python3-debian or python-debian is available in Debian, Fedora, etc.
from debian.debian_support import Version


logger = logging.getLogger('deb-build-snapshot')


class Failure(Exception):
    """An exception that does not normally provoke a traceback"""


class ShlexAndAppend(argparse.Action):
    def __call__(self, parser, namespace, values, option_string):
        value = getattr(namespace, self.dest, [])
        setattr(
            namespace,
            self.dest,
            list(value) + list(shlex.split(values)),
        )


class ExecutionEnvironment(metaclass=abc.ABCMeta):
    @property
    def is_temporary(self):
        # type: (...) -> bool
        return False

    @staticmethod
    def to_shell(cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        """cmd is the first argument of subprocess.call:
        a sequence of argv elements if shell is false, or a single
        shell one-liner if shell is true. Return an equivalent
        shell one-liner, suitable for running via sh -c or ssh.
        """
        if shell:
            assert isinstance(cmd, str)
            return cmd
        else:
            return ' '.join((shlex.quote(x) for x in cmd))

    @staticmethod
    def to_argv(cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> typing.List[str]
        """cmd is the first argument of subprocess.call:
        a sequence of argv elements if shell is false, or a single
        shell one-liner if shell is true. Return an equivalent
        argument vector.
        """
        if shell:
            assert isinstance(cmd, str)
            return ['sh', '-c', cmd]
        else:
            return list(cmd)

    @abc.abstractmethod
    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        """Log cmd and execute it.

        Return the exit status, or raise CalledProcessException if the
        exit status is a failure and may_fail is false.

        If shell is false, cmd is a sequence of argv elements;
        if true, cmd is a single string containing a shell one-liner.
        """

    @abc.abstractmethod
    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        """Same as do(), but capture and return the stdout from cmd.
        """

    @abc.abstractmethod
    def __enter__(self):
        # type: (...) -> ExecutionEnvironment
        """Start execution environment"""

    @abc.abstractmethod
    def __exit__(
        self,
        exc_type,                       # type: typing.Type[Exception]
        exc_val,                        # type: Exception
        exc_tb                          # type: typing.Any
    ):
        # type: (...) -> typing.Optional[bool]
        """Clean up execution environment"""


class LocalExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, *, debug=False):
        # type: (bool) -> None
        self.debug = debug

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return None

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        logging.debug('%s', cmd)

        if may_fail:
            return subprocess.call(cmd, shell=shell)
        else:
            subprocess.check_call(cmd, shell=shell)
            return 0

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        logging.debug('%s', cmd)
        return subprocess.check_output(
            cmd,
            shell=shell,
            universal_newlines=True,
        )


class RemoteExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, locally, builder):
        self.locally = locally
        self.builder = builder

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return None

    def do(self, cmd, shell=False, may_fail=False):
        # If we’re running on a TTY, run SSH in interactive mode, so that
        # the TERM variable is set correctly on the remote host and hence
        # we end up with compiler coloured output.
        interactive = '-t' if sys.stderr.isatty() else '-T'
        return self.locally.do([
            'ssh', interactive, self.builder,
            self.to_shell(cmd, shell)
        ], shell=False, may_fail=may_fail)

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        interactive = '-t' if sys.stderr.isatty() else '-T'
        return self.locally.capture([
            'ssh', interactive, self.builder, self.to_shell(cmd, shell),
        ])


class SudoExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, on_builder):
        # type: (ExecutionEnvironment) -> None
        self.on_builder = on_builder

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return None

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        if shell:
            assert isinstance(cmd, str)
            return self.on_builder.do(
                [
                    'sudo', '--',
                    'sh', '-c', cmd,
                ],
                shell=False, may_fail=may_fail,
            )
        else:
            assert isinstance(cmd, list)
            return self.on_builder.do(
                ['sudo', '--'] + cmd,
                shell=False, may_fail=may_fail,
            )

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        if shell:
            assert isinstance(cmd, str)
            return self.on_builder.capture(
                [
                    'sudo', '--',
                    'sh', '-c', cmd,
                ],
                shell=False,
            )
        else:
            assert isinstance(cmd, list)
            return self.on_builder.capture(
                ['sudo', '--'] + cmd,
                shell=False,
            )


class CanChdir(
    ExecutionEnvironment,
    metaclass=abc.ABCMeta
):
    def chdir(self, path):
        # type: (str) -> None
        """Change directory"""


class DirExecutionEnvironment(CanChdir):
    def __init__(self, on_builder, srcdir):
        # type: (ExecutionEnvironment, str) -> None
        self.on_builder = on_builder
        self.srcdir = srcdir

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return None

    def chdir(self, path):
        self.srcdir = path

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        return self.on_builder.do(
            'cd {} && {}'.format(
                shlex.quote(self.srcdir),
                self.to_shell(cmd, shell),
            ),
            shell=True, may_fail=may_fail,
        )

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        return self.on_builder.capture(
            'cd {} && {}'.format(
                shlex.quote(self.srcdir),
                self.to_shell(cmd, shell),
            ),
            shell=True,
        )


class _DockerLikeExecutionEnvironment(
    ExecutionEnvironment,
    metaclass=abc.ABCMeta
):
    _CLI = 'false'

    def __init__(self, on_builder, image, srcdir, tmpdir):
        # type: (ExecutionEnvironment, str, str, str) -> None
        self.image = image
        self.instance_id = ''
        self.__srcdir = srcdir
        self.__tmpdir = tmpdir
        self._home = on_builder.capture(
            'echo "$HOME"', shell=True,
        ).strip()
        self.on_builder = on_builder

    @property
    def is_temporary(self):
        return True

    def __enter__(self):
        assert not self.instance_id

        self.instance_id = self.on_builder.capture(
            [
                self._CLI, 'run',
                '--detach',
                '--init',
                '--rm',
            ] + self._get_volumes() + [
                '--',
                self.image,
                'sleep', 'infinity',
            ],
        ).strip()

        return self

    def chdir(self, path):
        self.__srcdir = path

    def _get_volumes(self):
        # type: (...) -> typing.List[str]
        return [
            '-v', '{}:{}:rw'.format(
                self.__tmpdir, self.__tmpdir,
            ),
        ]

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.instance_id:
            self.on_builder.capture(
                [self._CLI, 'stop', self.instance_id],
            )

        return None

    def _get_uidgid(self):
        return '0:0'

    def _get_argv(
        self,
        cmd,                    # type: typing.Union[str, typing.List[str]]
        shell=False,            # type: bool
        as_root=False           # type: bool
    ):
        # type: (...) -> typing.List[str]

        argv = [
            self._CLI, 'exec',
            '-u', '0:0' if as_root else self._get_uidgid(),
            '-w', self.__srcdir,
        ]

        if sys.stderr.isatty():
            argv.append('-it')

        return argv + [
            '--',
            self.instance_id,
        ] + self.to_argv(cmd, shell)

    def do(self, cmd, shell=False, may_fail=False, as_root=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool, bool) -> int
        return self.on_builder.do(
            self._get_argv(cmd, shell=shell, as_root=as_root),
            may_fail=may_fail,
            shell=False,
        )

    def capture(self, cmd, shell=False, as_root=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> str
        return self.on_builder.capture(
            self._get_argv(cmd, shell=shell, as_root=as_root),
            shell=False,
        )


class _DockerLikeRootExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, docker_like):
        # type: (_DockerLikeExecutionEnvironment) -> None
        self.docker_like = docker_like

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return None

    def do(self, cmd, shell=False, may_fail=False):
        return self.docker_like.do(
            cmd, shell=shell, may_fail=may_fail, as_root=True,
        )

    def capture(self, cmd, shell=False):
        return self.docker_like.capture(
            cmd, shell=shell, as_root=True,
        )


class DockerExecutionEnvironment(_DockerLikeExecutionEnvironment):
    _CLI = 'docker'

    def __init__(self, on_builder, image, srcdir, tmpdir):
        # type: (ExecutionEnvironment, str, str, str) -> None
        super().__init__(on_builder, image, srcdir, tmpdir)
        self.__uid = int(on_builder.capture('id -u', shell=True).strip())
        self.__gid = int(on_builder.capture('id -g', shell=True).strip())
        self.__stack = contextlib.ExitStack()

        if on_builder.do(
            ['test', '-w', '/run/docker.sock'], shell=False, may_fail=True
        ) != 0:
            self.on_builder = self.__stack.enter_context(
                SudoExecutionEnvironment(on_builder)
            )

    def _get_volumes(self):
        # type: (...) -> typing.List[str]
        return super()._get_volumes() + [
            '-v', '/etc/passwd:/etc/passwd:ro',
            '-v', '/etc/group:/etc/group:ro',
            '-v', '{}/.ccache:{}/.ccache:rw'.format(
                self._home, self._home,
            ),
        ]

    def _get_uidgid(self):
        return '{}:{}'.format(self.__uid, self.__gid)

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.__stack.__exit__(exc_type, exc_val, exc_tb)
        return super().__exit__(exc_type, exc_val, exc_tb)


class PodmanExecutionEnvironment(_DockerLikeExecutionEnvironment):
    _CLI = 'podman'

    def _get_volumes(self):
        # type: (...) -> typing.List[str]
        return super()._get_volumes() + [
            '-v', '{}/.ccache:/root/.ccache:rw'.format(
                self._home,
            ),
        ]


class SchrootExecutionEnvironment(CanChdir):
    def __init__(self, on_builder, chroot, srcdir):
        # type: (ExecutionEnvironment, str, str) -> None
        self.on_builder = on_builder
        self.chroot = chroot
        self.srcdir = srcdir

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return None

    def chdir(self, path):
        self.srcdir = path

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        return self.on_builder.do(
            self._get_argv(cmd, shell),
            may_fail=may_fail,
            shell=False,
        )

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        return self.on_builder.capture(
            self._get_argv(cmd, shell),
            shell=False,
        )

    def _get_argv(
        self,
        cmd,                    # type: typing.Union[str, typing.List[str]]
        shell=False,            # type: bool
    ):
        # type: (...) -> typing.List[str]

        argv = [
            'schroot',
            '-c', self.chroot,
            '-d', self.srcdir,
        ]

        return argv + ['--'] + self.to_argv(cmd, shell)


SnapshotMarker = enum.Enum('SnapshotMarker', 'COUNTER DATE')
Upstreamness = enum.Enum('Upstreamness', 'UPSTREAM PACKAGING AUTO')


class SnapshotBuilder:
    def __init__(self, args):
        # type: (argparse.Namespace) -> None
        self.args = args
        self.srcdir = '.'
        self.stack = contextlib.ExitStack()
        self.locally = self.stack.enter_context(
            LocalExecutionEnvironment(debug=args.debug)
        )
        self.on_builder = None  # type: typing.Optional[ExecutionEnvironment]
        self.in_srcdir = None   # type: typing.Optional[CanChdir]
        self.upstream_env = None    # type: typing.Optional[CanChdir]
        self.deb_env = None     # type: typing.Optional[CanChdir]
        self.root_deb_env = None    # type: typing.Optional[CanChdir]

        self.deb_git_version_gen = [
            sys.executable,
            os.path.join(
                os.path.dirname(os.path.realpath(sys.argv[0])),
                'deb-git-version-gen',
            )
        ]

        if not os.path.exists(self.deb_git_version_gen[1]):
            self.deb_git_version_gen = ['deb-git-version-gen']

    def do_merges(self):
        # type: () -> None
        assert self.in_srcdir is not None
        for r in self.args.remotes:
            name, uri = r.split('=', 1)
            self.in_srcdir.do(['git', 'remote', 'remove', name], may_fail=True)
            self.in_srcdir.do(['git', 'remote', 'add', name, uri])
            self.in_srcdir.do(['git', 'remote', 'update', '--prune', name])

        for m in self.args.merges:
            self.in_srcdir.do([
                'git', 'merge',
                '-s', self.args.merge_strategy,
                '-m', 'Automatic merge for snapshot',
                '--allow-unrelated-histories',
            ] + ['-X{}'.format(x) for x in self.args.merge_options] + [
                m,
            ])

    def set_srcdir(self, srcdir, tmpdir):
        # type: (str, str) -> None
        self.srcdir = srcdir

        on_builder = self.on_builder
        assert on_builder is not None

        in_srcdir = self.in_srcdir

        if in_srcdir is None:
            self.in_srcdir = in_srcdir = self.stack.enter_context(
                DirExecutionEnvironment(on_builder, self.srcdir)
            )
        else:
            in_srcdir.chdir(srcdir)

        upstream_env = self.upstream_env    # type: typing.Optional[CanChdir]

        if upstream_env is not None:
            upstream_env.chdir(srcdir)
        elif self.args.podman is not None:
            upstream_env = self.stack.enter_context(
                PodmanExecutionEnvironment(
                    on_builder, self.args.podman, srcdir, tmpdir,
                )
            )
        elif self.args.docker is not None:
            upstream_env = self.stack.enter_context(
                DockerExecutionEnvironment(
                    on_builder, self.args.docker, srcdir, tmpdir,
                )
            )
        elif self.args.schroot is not None:
            upstream_env = self.stack.enter_context(
                SchrootExecutionEnvironment(
                    on_builder, self.args.schroot, srcdir,
                )
            )
        else:
            upstream_env = self.in_srcdir

        assert upstream_env is not None
        self.upstream_env = upstream_env

        deb_env = self.deb_env          # type: typing.Optional[CanChdir]

        if deb_env is not None:
            deb_env.chdir(srcdir)
        elif self.args.deb_podman is not None:
            deb_env = self.stack.enter_context(
                PodmanExecutionEnvironment(
                    on_builder, self.args.deb_podman, srcdir, tmpdir,
                )
            )
        elif self.args.deb_docker is not None:
            deb_env = self.stack.enter_context(
                DockerExecutionEnvironment(
                    on_builder, self.args.deb_docker, srcdir, tmpdir,
                )
            )
        elif self.args.deb_schroot is not None:
            deb_env = self.stack.enter_context(
                SchrootExecutionEnvironment(
                    on_builder, self.args.deb_schroot, srcdir,
                )
            )
        else:
            deb_env = upstream_env

        assert deb_env is not None
        self.deb_env = deb_env

        if isinstance(deb_env, _DockerLikeExecutionEnvironment):
            self.root_deb_env = self.stack.enter_context(
                _DockerLikeRootExecutionEnvironment(deb_env)
            )
        elif deb_env.capture(['id', '-u']).strip() == '0':
            self.root_deb_env = deb_env
        else:
            self.root_deb_env = self.stack.enter_context(
                SudoExecutionEnvironment(deb_env)
            )

    def main(self):
        # type: () -> None
        """Main function: build the snapshot.
        """
        with self.stack:
            self._main()

    def _main(self):
        if self.args.builder == 'localhost':
            self.on_builder = self.locally
        else:
            self.on_builder = self.stack.enter_context(
                RemoteExecutionEnvironment(
                    self.locally, self.args.builder,
                )
            )

        tmpdir = self.on_builder.capture([
            'mktemp', '-d', '/tmp/build-snapshot.XXXXXXXXXX']).strip('\n')
        self.on_builder.do(['mkdir', tmpdir + '/s'])
        self.set_srcdir(tmpdir + '/s', tmpdir)

        if self.args.builder == 'localhost':
            self.locally.do([
                'rsync', '-az', '--delete', './', self.srcdir + '/'])
        else:
            self.locally.do([
                'rsync', '-az', '--delete', './',
                self.args.builder + ':' + self.srcdir + '/'])

        self.do_merges()

        self.dpkg_version = self.deb_env.capture([
            'dpkg-query', '-W', '-f${Version}', 'dpkg',
        ])

        self.apt_version = self.deb_env.capture([
            'dpkg-query', '-W', '-f${Version}', 'apt',
        ])

        # Check build-dependencies first, so we don't waste time running
        # autoreconf if it's going to fail.
        if (
            self.args.check_build_deps
            and not self.args.source_only
            and not self.args.print_version
        ):
            if (
                self.deb_env.is_temporary
                and sys.stderr.isatty()
                and self.apt_version >= Version('1.1')
            ):
                if self.deb_env.do(
                    ['dpkg-checkbuilddeps'], may_fail=True
                ) != 0:
                    self.root_deb_env.do([
                        'apt-get',
                        '--no-install-recommends',
                        'build-dep',
                        './',
                    ])

                if self.args.i386 and self.deb_env.do(
                    ['dpkg-checkbuilddeps', '-ai386', '-B'], may_fail=True
                ) != 0:
                    self.root_deb_env.do([
                        'apt-get',
                        '-ai386',
                        '--arch-only',
                        '--no-install-recommends',
                        'build-dep',
                        './',
                    ])
            else:
                self.deb_env.do(['dpkg-checkbuilddeps'])

                if self.args.i386:
                    self.deb_env.do(['dpkg-checkbuilddeps', '-ai386', '-B'])

        source_package = self.in_srcdir.capture([
            'dpkg-parsechangelog', '-SSource']).strip('\n')

        argv = self.deb_git_version_gen + [
            '--json',
        ]

        if self.args.upstream is Upstreamness.AUTO:
            argv.append('--auto-upstream')
        elif self.args.upstream is Upstreamness.UPSTREAM:
            argv.append('--upstream')
        elif self.args.upstream is Upstreamness.PACKAGING:
            argv.append('--packaging-only')
        else:
            assert self.args.upstream is None

        if self.args.debug:
            argv.append('--debug')

        if self.args.release:
            argv.append('--release')
        elif self.args.release is None:
            argv.append('--guess-release')

        if self.args.packaging_tag:
            argv.append('--packaging-tag=' + self.args.packaging_tag)
        if self.args.vendor:
            argv.append('--vendor=' + self.args.vendor)

        if self.args.snapshot_marker is SnapshotMarker.DATE:
            argv.append('--date-based')
        elif self.args.snapshot_marker is SnapshotMarker.COUNTER:
            argv.append('--counter-based')
        else:
            assert self.args.snapshot_marker is None

        if self.args.branch_marker is not None:
            argv.append('--branch-marker')
            argv.append(self.args.branch_marker)

        if self.args.build_suffix is not None:
            argv.append('--build-suffix')
            argv.append(self.args.build_suffix)

        if self.args.avoid_build_suffix is not None:
            argv.append('--avoid-build-suffix')
            argv.append(self.args.avoid_build_suffix)

        try:
            text = self.locally.capture(argv)
        except subprocess.CalledProcessError as e:
            if e.returncode == 1 and not self.args.debug:
                raise Failure('deb-git-version-gen failed')
            else:
                raise

        logging.debug('%s', text)
        version_info = json.loads(text)
        is_native = version_info['is_native']
        is_upstream = version_info['is_upstream']
        snapshot_version = Version(version_info['snapshot_version'])

        if self.args.upstream is Upstreamness.PACKAGING and is_upstream:
            raise AssertionError(
                'deb-git-version-gen --packaging returned is_upstream:true'
            )

        if '/' in str(snapshot_version):
            raise Failure('Bad version {!r}'.format(snapshot_version))
        if '/' in source_package:
            raise Failure(
                'Bad package name {!r}'.format(source_package))

        if self.args.print_version:
            print(snapshot_version)
            self.on_builder.do(['rm', '-rf', shlex.quote(tmpdir)])
            return

        srcdir = '{}/{}-{}'.format(
            tmpdir,
            source_package,
            snapshot_version,
        )
        self.on_builder.do(['mv', tmpdir + '/s', srcdir])
        self.set_srcdir(srcdir, tmpdir)

        path = self.on_builder.capture('echo $PATH', shell=True).strip('\n')
        if ':/usr/lib/ccache:' not in ':{}:'.format(path):
            path = '/usr/lib/ccache:{}'.format(path)

        aclocal_path = []

        for i, d in enumerate(self.args.aclocal_search):
            if self.args.builder == 'localhost':
                remote = d
            else:
                remote = tmpdir + '/aclocal.{}'.format(i)
                self.locally.do([
                    'rsync', '-az', '--delete', d + '/',
                    self.args.builder + ':' + remote + '/',
                ])

            aclocal_path.append(remote)

        for d in self.args.aclocal_search_builder:
            aclocal_path.append(d)

        is_autotools = os.path.exists('configure.ac')
        is_meson = os.path.exists('meson.build')
        dist_tar_dir = None     # type: typing.Optional[str]

        # Assume that packages with /configure.ac are Autotools.
        # We probe for configure.ac instead of testing for is_native,
        # because we could conceivably have native packages that use
        # Autotools and we want to exercise distcheck for those;
        # we don't do this unconditionally because we have some native
        # packages that don't use Autotools.
        if (
            is_upstream and
            not is_native and
            (is_meson or is_autotools) and
            # If we want a source package, we must at least `make dist`.
            # If we want to do build-time tests, we presumably want to
            # also verify that it distchecks. However, if we just want
            # binary packages as fast as possible (--no-check),
            # skip it.
            (self.args.source or self.args.check)
        ):
            if os.path.exists('.git'):
                self.in_srcdir.do(['git', 'status', '-u'])
                if self.args.git_clean:
                    self.in_srcdir.do(['git', 'clean', '-fxd'])

            if is_autotools:
                self.upstream_env.do([
                    'env',
                    'ACLOCAL_PATH=' + ':'.join(aclocal_path),
                    'NOCONFIGURE=1',
                    './autogen.sh',
                ])
                self.upstream_env.do(
                    ['./configure'] + self.args.configure_options)

                if self.args.check:
                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path), 'make',
                        '-j{}'.format(self.args.jobs), 'check',
                        'VERBOSE=1',
                    ])
                    self.upstream_env.do([
                        'find', '.', '-name', 'test-suite.log', '-exec',
                        'head', '-v', '-n10000', '{}', ';'])
                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path), 'make',
                        '-j{}'.format(self.args.jobs), 'distcheck',
                        'VERBOSE=1',
                    ])
                else:
                    if not self.args.source_only:
                        self.upstream_env.do([
                            'env', 'PATH={}'.format(path), 'make',
                            '-j{}'.format(self.args.jobs), 'VERBOSE=1',
                        ])

                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path), 'make',
                        '-j{}'.format(self.args.jobs), 'dist', 'VERBOSE=1',
                    ])

                dist_tar_dir = self.srcdir
            elif is_meson:
                self.upstream_env.do(
                    ['meson'] + self.args.configure_options + ['../builddir'])
                self.upstream_env.do([
                    'env', 'PATH={}'.format(path),
                    'ninja',
                    '-j{}'.format(self.args.jobs),
                    '-C', '../builddir',
                ])

                if self.args.check:
                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path),
                        'meson',
                        'test',
                        '-C', '../builddir',
                        '-v',
                    ])

                self.upstream_env.do([
                    'env', 'PATH={}'.format(path),
                    'ninja',
                    '-j{}'.format(self.args.jobs),
                    '-C', '../builddir',
                    'dist',
                ])

                dist_tar_dir = tmpdir + '/builddir/meson-dist'
            else:
                raise AssertionError('Cannot dist non-Autotools non-Meson')

        if self.args.source and not is_native:
            if dist_tar_dir is not None:
                tarballs = self.in_srcdir.capture(
                    'ls -1 {}/*.tar.*'.format(shlex.quote(dist_tar_dir)),
                    shell=True,
                ).strip().splitlines()
                got_tarballs = False

                for tarball in tarballs:
                    for ext in '.tar.xz', '.tar.gz':
                        if tarball.endswith(ext):
                            self.in_srcdir.do('mv {} ../{}_{}.orig{}'.format(
                                tarball, shlex.quote(source_package),
                                shlex.quote(
                                    snapshot_version.upstream_version
                                ),
                                ext),
                                shell=True)
                            got_tarballs = True

                if not got_tarballs:
                    raise Failure(
                        'No supported tarball found in {}'.format(
                            tarballs,
                        ),
                    )

            else:
                tarball_format = '{}_{}.orig*.tar.*'.format(
                    source_package, snapshot_version.upstream_version)
                tarballs = glob.glob('{}/{}'.format(
                    self.args.orig_dir, tarball_format))
                got_tarballs = False

                if tarballs:
                    extra_format = '{}_{}.orig-*.tar.*'.format(
                        source_package, snapshot_version.upstream_version)
                    extras = glob.glob('{}/{}'.format(
                        self.args.orig_dir, extra_format))
                    tarballs = tarballs + extras

                    if self.args.builder == 'localhost':
                        dest = '{}/'.format(tmpdir)
                    else:
                        dest = '{}:{}/'.format(self.args.builder, tmpdir)

                    for t in tarballs:
                        self.locally.do([
                            'rsync', '-P', '--copy-links', t, dest])

                    got_tarballs = True

                if not got_tarballs:
                    try:
                        self.in_srcdir.do([
                            'git', 'branch', 'pristine-tar',
                            'origin/pristine-tar'])
                    except subprocess.CalledProcessError:
                        # assume branch already exists
                        pass
                    try:
                        for line in self.in_srcdir.capture([
                            'pristine-tar',
                            'list'
                        ]).splitlines():
                            if fnmatch.fnmatch(line, tarball_format):
                                self.in_srcdir.do([
                                    'pristine-tar', 'checkout', line])
                                self.in_srcdir.do(['mv', line, '..'])
                                got_tarballs = True
                    except subprocess.CalledProcessError:
                        pass

                if not got_tarballs:
                    self.on_builder.do(
                        'cd {} && apt-get --download-only source {}'.format(
                            shlex.quote(tmpdir),
                            shlex.quote(source_package),
                        ), shell=True)

        if os.path.exists('.git'):
            self.in_srcdir.do(['git', 'status', '-u'])
            if self.args.git_clean:
                self.in_srcdir.do(['git', 'clean', '-fxd'])

        if self.args.overlay:
            tarball = self.in_srcdir.capture(
                'ls -1 ../{}_{}.orig.tar.*'.format(
                    source_package,
                    snapshot_version.upstream_version,
                ),
                shell=True)
            tarball = tarball.strip()

            if '\n' in tarball:
                raise Failure(
                    'Cannot unpack multiple tarballs for --overlay: '
                    '{!r}'.format(tarball.split('\n')))
            elif not tarball:
                raise Failure('No tarballs found for --overlay')

            self.in_srcdir.do([
                'tar',
                '-x',
                '-f', tarball,
                '--auto-compress',
                '--strip-components=1',
                '--anchored',
                '--no-wildcards-match-slash',
                '--exclude=*/debian',
            ])
            self.in_srcdir.do(['git', 'status', '-u'])

        for command in self.args.before_dpkg_buildpackage:
            self.deb_env.do(command, shell=True)

        # If the version in the changelog is not already what we want,
        # make it so
        if 'dch' in version_info:
            self.in_srcdir.do(version_info['dch'], shell=True)

        args = ['-us', '-uc', '-i', '-I']

        if self.args.source_only:
            build_what = ['-S']
        elif self.args.source:
            build_what = []
        else:
            build_what = ['-b']

        deb_build_options = set(self.args.deb_build_options)

        if not self.args.check:
            deb_build_options.add('nocheck')

        if self.dpkg_version >= Version('1.18.2'):
            args.append('-J{}'.format(self.args.jobs))

        if self.args.source_only or not self.args.check_build_deps:
            # The short form is more backwards-compatible
            args.append('-d')

        args.extend(self.args.dpkg_buildpackage_options)

        self.deb_env.do([
            'env',
            'ACLOCAL_PATH=' + ':'.join(aclocal_path),
            'DEB_BUILD_OPTIONS={}'.format(' '.join(deb_build_options)),
            'PATH={}'.format(path),
            'dpkg-buildpackage'] + build_what + args)
        self.in_srcdir.do([
            'find', '.', '-name', 'test-suite.log', '-exec',
            'head', '-v', '-n10000', '{}', ';'])

        if os.path.exists('.git'):
            self.in_srcdir.do(['git', 'status', '-u'])

        if self.args.i386 and not self.args.source_only:
            if os.path.exists('.git'):
                if self.args.git_clean:
                    self.in_srcdir.do(['git', 'clean', '-fxd'])

            self.deb_env.do([
                'env',
                'ACLOCAL_PATH=' + ':'.join(aclocal_path),
                'CC=gcc -m32',
                'DEB_BUILD_OPTIONS={}'.format(' '.join(deb_build_options)),
                'PATH={}'.format(path),
                'dpkg-buildpackage', '-ai386', '-B'] + args)
            self.in_srcdir.do([
                'find', '.', '-name', 'test-suite.log', '-exec',
                'head', '-v', '-n10000', '{}', ';'])

            if os.path.exists('.git'):
                self.in_srcdir.do(['git', 'status', '-u'])

        if not self.args.source_only and self.deb_env.do(
            'command -v debc >/dev/null', shell=True, may_fail=True
        ) == 0:
            self.deb_env.do('debc ../*.changes || :', shell=True)

            if self.args.i386:
                self.deb_env.do('debc -ai386 ../*.changes || :', shell=True)

        if self.args.download is not None:
            if self.args.builder != 'localhost':
                self.locally.do([
                    'rsync', '-zvP',
                    '{}:{}/*'.format(self.args.builder, tmpdir),
                    '{}/'.format(self.args.download)])
            else:
                self.locally.do([
                    'rsync', '-zvP',
                ] + list(glob.glob('{}/*'.format(tmpdir))) + [
                    '{}/'.format(self.args.download)
                ])

        if ((self.args.install or self.args.install_all) and
                not self.args.source_only):
            if (self.args.builder == 'localhost' and
                    not self.args.force_local_install):
                raise SystemExit(
                    'Refusing to install built packages locally without '
                    '--force-local-install')

            if self.apt_version >= Version('1.1'):
                install = 'apt install'

                if self.args.install_all:
                    install = install + ' --allow-downgrades -y'
                else:
                    install = install + ' --only-upgrade'

                install = install + ' {}/*.deb'.format(shlex.quote(tmpdir))

                self.root_deb_env.do(install, shell=True)
            elif self.deb_env.do(
                'command -v debi >/dev/null', shell=True, may_fail=True
            ) == 0:
                debi = ['debi', '--no-conf', '--with-depends',
                        '--debs-dir', tmpdir]

                if not self.args.install_all:
                    debi.append('--upgrade')

                self.root_deb_env.do(debi)
            else:
                install = 'dpkg -i'

                if not self.args.install_all:
                    install = install + ' -O'

                self.root_deb_env.do(
                    '{} {}/*.deb'.format(install, shlex.quote(tmpdir)),
                    shell=True)
                self.root_deb_env.do(['apt-get', '-f', 'install'])

            if (os.path.exists('debian/tests/control') and
                    self.deb_env.do(
                        'command -v sadt >/dev/null', shell=True, may_fail=True
            ) == 0):
                # sadt might do a compile-test, so use ccache here too
                self.deb_env.do([
                    'env', 'PATH={}'.format(path), 'sadt', '--verbose',
                    '--built-source-tree',
                ])

        if not self.args.keep:
            self.on_builder.do(['rm', '-rf', shlex.quote(tmpdir)])


def main():
    # type: () -> None

    logging.getLogger().setLevel(logging.INFO)

    if sys.stderr.isatty():
        try:
            import colorlog
        except ImportError:
            pass
        else:
            formatter = colorlog.ColoredFormatter(
                '%(log_color)s%(levelname)s:%(name)s:%(reset)s %(message)s')
            handler = logging.StreamHandler()
            handler.setFormatter(formatter)
            logging.getLogger().addHandler(handler)

    # This is a no-op if we already attached a (coloured log) handler
    logging.basicConfig()

    parser = argparse.ArgumentParser(
        description='Make a snapshot of a package')

    parser.add_argument(
        '--source', '-s',
        help='Build a source package (default: in-tree binary-only build)',
        action='store_true', default=False)
    parser.add_argument(
        '--source-only', '-S',
        help='Build a source package only',
        action='store_true', default=False, dest='source_only')
    parser.add_argument(
        '--dpkg-buildpackage-option', '--debuild-option',
        help='Pass arbitrary option to dpkg-buildpackage',
        action='append', dest='dpkg_buildpackage_options', default=[])
    parser.add_argument(
        '--i386', help='Build i386 binaries too',
        action='store_true', default=False)
    parser.add_argument(
        '--no-check-builddeps',
        dest='check_build_deps', action='store_false', default=True,
        help='do not check build-dependencies',
    )

    parser.add_argument(
        '--no-git-clean',
        help='do not run `git clean`',
        action='store_false', dest='git_clean', default=True)

    parser.add_argument(
        '--no-check', '--nocheck',
        help='do not run build-time tests',
        action='store_false', dest='check', default=True)
    parser.add_argument(
        '--build-option', '-O',
        help='append this to DEB_BUILD_OPTIONS (noopt, nocheck etc.)',
        action='append', dest='deb_build_options', default=[])

    parser.add_argument(
        '--before-dpkg-buildpackage',
        help='run shell commands before dpkg-buildpackage, for example '
             '"debian/rules debian/control || debian/rules debian/control" '
             'for src:linux; may be repeated to add more commands',
        action='append', default=[])

    parser.add_argument(
        '--packaging', '--packaging-only', '-p',
        help=(
            'assume that we are only packaging this package '
            '(requires orig tarball, pristine-tar branch or ability to '
            'obtain orig tarballs from `apt-get source`)'
        ),
        action='store_const', dest='upstream',
        const=Upstreamness.PACKAGING,
        default=None,
    )
    parser.add_argument(
        '--upstream', '-u',
        help='assume that we are the upstream for this package (default)',
        action='store_const',
        const=Upstreamness.UPSTREAM,
        default=None,
    )
    parser.add_argument(
        '--auto-upstream',
        help=(
            'assume that we are the upstream for this package, '
            'but do pure packaging changes as packaging revisions '
            '(requires orig tarball, pristine-tar branch or ability to '
            'obtain orig tarballs from `apt-get source`)'
        ),
        action='store_const', dest='upstream',
        const=Upstreamness.AUTO,
        default=None,
    )
    parser.add_argument(
        '--release', help='make a real release, not a snapshot',
        action='store_true', default=False)
    parser.add_argument(
        '--guess-release',
        help='make a real release if we are currently at a tag',
        action='store_const', const=None, dest='release')
    parser.add_argument(
        '--orig-dir',
        help='with -p, look for orig.tar.gz here (default: same as '
             '--download, or "..")',
        default=None)
    parser.add_argument(
        '--overlay',
        help='unpack orig.tar.* into source directory before build',
        action='store_true', default=False)

    parser.add_argument(
        '--packaging-tag', '--debian-tag',
        help='Format for git tags corresponding to a packaging version',
        default=None)
    parser.add_argument(
        '--vendor', '--distro', help='DEP-14 vendor/distribution string',
        default=None)

    parser.add_argument(
        '--counter-based',
        help='base version numbers on commit count (default)',
        action='store_const', dest='snapshot_marker',
        const=SnapshotMarker.COUNTER,
        default=None,
    )
    parser.add_argument(
        '--date-based', help='base version numbers on build date',
        action='store_const', dest='snapshot_marker',
        const=SnapshotMarker.DATE,
        default=None,
    )

    parser.add_argument(
        '--branch-marker', help='add this string to snapshot marker',
        default=None,
    )
    parser.add_argument(
        '--build-suffix',
        help=(
            'ensure snapshot marker is greater than this, e.g. "+b" for '
            'Debian binNMUs'
        ),
        default=None,
    )
    parser.add_argument(
        '--avoid-build-suffix',
        help=(
            'use this marker to avoid being less than --build-suffix, '
            'e.g. "+snapshot" (default: same as --build-suffix)'
        ),
        default=None,
    )

    parser.add_argument(
        '--keep', '-k',
        help='Keep temporary built tree after a successful build',
        action='store_true', default=False)
    parser.add_argument(
        '--download', '-d',
        help='Download build products to DOWNLOAD/ (existing files will '
             'be overwritten; gbp users might use "../build-area")',
        default=None)
    parser.add_argument(
        '--install', '-i',
        help='Upgrade build products on remote host',
        action='store_true', default=False)
    parser.add_argument(
        '--force-local-install',
        help='Force -i or -I to work even if building on localhost',
        action='store_true', default=False)
    parser.add_argument(
        '--install-all', '-I',
        help='Install build products on remote host even if not already '
             'installed',
        action='store_true', default=False)

    parser.add_argument(
        '--configure-option', metavar='OPTION', dest='configure_options',
        action='append', default=[],
        help='Pass OPTION to ./configure or meson (may be repeated), '
             'for example --configure-option=--prefix=/usr',
    )
    parser.add_argument(
        '--configure-options', metavar='OPTIONS', dest='configure_options',
        action=ShlexAndAppend,
        help='Split OPTIONS as if for a shell and pass them all to '
             './configure or meson, for example '
             '--configure-options="--prefix=/usr CC=\"ccache gcc\""',
    )
    parser.add_argument(
        '-j', '--jobs', type=int,
        help='Run this many jobs in parallel', default=5)

    parser.add_argument(
        '--aclocal-search-builder', metavar='DIR', action='append',
        default=[],
        help='Look for updated Autoconf m4 macros in DIR on HOSTNAME')
    parser.add_argument(
        '--aclocal-search', metavar='DIR', action='append',
        default=[],
        help='Look for updated Autoconf m4 macros in DIR on machine '
        'where deb-build-snapshot was invoked')

    parser.add_argument(
        '--merge', action='append', metavar='COMMIT',
        dest='merges', default=[],
        help='merge Git branches or tags, for example "--merge origin/wip"')
    parser.add_argument(
        '--merge-strategy', metavar='STRATEGY',
        dest='merge_strategy', default='recursive',
        help='Use "git merge -s STRATEGY"')
    parser.add_argument(
        '--merge-option', metavar='OPTION',
        dest='merge_options', action='append', default=[],
        help='Use "git merge -X OPTION [-X OPTION2...]"')
    parser.add_argument(
        '--add-remote', action='append', metavar='NAME=URI',
        dest='remotes', default=[],
        help='add extra Git remotes for use with --merge, in the format '
             '"--add-remote upstream=https://git.example.com/hello"')

    parser.add_argument(
        '--podman', metavar='IMAGE', default=None,
        help='Enter a podman container based on IMAGE on remote host',
    )
    parser.add_argument(
        '--docker', metavar='IMAGE', default=None,
        help='Enter a Docker container based on IMAGE on remote host',
    )
    parser.add_argument(
        '--schroot', metavar='CHROOT', default=None,
        help='Enter CHROOT on remote host (it must share $TMPDIR with '
             'the host system)')
    parser.add_argument(
        '--deb-podman', metavar='IMAGE', default=None,
        help=(
            'Enter a podman container based on IMAGE on remote host '
            'for Debian package build only'
        ),
    )
    parser.add_argument(
        '--deb-docker', metavar='IMAGE', default=None,
        help=(
            'Enter a Docker container based on IMAGE on remote host '
            'for Debian package build only'
        ),
    )
    parser.add_argument(
        '--deb-schroot', metavar='CHROOT', default=None,
        help='Enter CHROOT on remote host for Debian package build only')

    parser.add_argument(
        'builder', metavar='HOSTNAME',
        help='Remote host to use for the build (no default, use '
             '"localhost" for a local build)')

    parser.add_argument(
        '--print-version',
        help='Print the version number that would be used, then exit',
        action='store_true', default=False)

    parser.add_argument(
        '--debug',
        help='Give more diagnostics',
        action='store_true')

    args = parser.parse_args()

    if args.debug:
        logging.getLogger().setLevel(logging.DEBUG)

    if args.orig_dir is None:
        if args.download is None:
            args.orig_dir = '..'
        else:
            args.orig_dir = args.download

    if args.source_only:
        args.source = True

    try:
        SnapshotBuilder(args).main()
    except (Failure, subprocess.CalledProcessError) as e:
        if args.debug:
            raise
        else:
            logger.error('%s', e)
            sys.exit(1)


if __name__ == '__main__':
    main()
