diff options
Diffstat (limited to '')
-rw-r--r-- | python/mozversioncontrol/.isort.cfg | 3 | ||||
-rw-r--r-- | python/mozversioncontrol/mozversioncontrol/__init__.py | 827 | ||||
-rw-r--r-- | python/mozversioncontrol/mozversioncontrol/repoupdate.py | 37 | ||||
-rw-r--r-- | python/mozversioncontrol/setup.py | 28 | ||||
-rw-r--r-- | python/mozversioncontrol/test/conftest.py | 84 | ||||
-rw-r--r-- | python/mozversioncontrol/test/python.ini | 10 | ||||
-rw-r--r-- | python/mozversioncontrol/test/test_branch.py | 57 | ||||
-rw-r--r-- | python/mozversioncontrol/test/test_commit.py | 72 | ||||
-rw-r--r-- | python/mozversioncontrol/test/test_context_manager.py | 28 | ||||
-rw-r--r-- | python/mozversioncontrol/test/test_push_to_try.py | 81 | ||||
-rw-r--r-- | python/mozversioncontrol/test/test_update.py | 63 | ||||
-rw-r--r-- | python/mozversioncontrol/test/test_workdir_outgoing.py | 108 | ||||
-rw-r--r-- | python/mozversioncontrol/test/test_working_directory.py | 46 |
13 files changed, 1444 insertions, 0 deletions
diff --git a/python/mozversioncontrol/.isort.cfg b/python/mozversioncontrol/.isort.cfg new file mode 100644 index 0000000000..9fa4dfd66f --- /dev/null +++ b/python/mozversioncontrol/.isort.cfg @@ -0,0 +1,3 @@ +[settings] +profile=black +known_first_party=mozversioncontrol
\ No newline at end of file diff --git a/python/mozversioncontrol/mozversioncontrol/__init__.py b/python/mozversioncontrol/mozversioncontrol/__init__.py new file mode 100644 index 0000000000..b847adf8f1 --- /dev/null +++ b/python/mozversioncontrol/mozversioncontrol/__init__.py @@ -0,0 +1,827 @@ +# 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/. + +import abc +import errno +import os +import re +import shutil +import subprocess +from pathlib import Path +from typing import Optional, Union + +from mach.util import to_optional_path +from mozfile import which +from mozpack.files import FileListFinder + + +class MissingVCSTool(Exception): + """Represents a failure to find a version control tool binary.""" + + +class MissingVCSInfo(Exception): + """Represents a general failure to resolve a VCS interface.""" + + +class MissingConfigureInfo(MissingVCSInfo): + """Represents error finding VCS info from configure data.""" + + +class MissingVCSExtension(MissingVCSInfo): + """Represents error finding a required VCS extension.""" + + def __init__(self, ext): + self.ext = ext + msg = "Could not detect required extension '{}'".format(self.ext) + super(MissingVCSExtension, self).__init__(msg) + + +class InvalidRepoPath(Exception): + """Represents a failure to find a VCS repo at a specified path.""" + + +class MissingUpstreamRepo(Exception): + """Represents a failure to automatically detect an upstream repo.""" + + +class CannotDeleteFromRootOfRepositoryException(Exception): + """Represents that the code attempted to delete all files from the root of + the repository, which is not permitted.""" + + +def get_tool_path(tool: Union[str, Path]): + tool = Path(tool) + """Obtain the path of `tool`.""" + if tool.is_absolute() and tool.exists(): + return str(tool) + + path = to_optional_path(which(str(tool))) + if not path: + raise MissingVCSTool( + f"Unable to obtain {tool} path. Try running " + "|mach bootstrap| to ensure your environment is up to " + "date." + ) + return str(path) + + +class Repository(object): + """A class wrapping utility methods around version control repositories. + + This class is abstract and never instantiated. Obtain an instance by + calling a ``get_repository_*()`` helper function. + + Clients are recommended to use the object as a context manager. But not + all methods require this. + """ + + __metaclass__ = abc.ABCMeta + + def __init__(self, path: Path, tool: str): + self.path = str(path.resolve()) + self._tool = Path(get_tool_path(tool)) + self._version = None + self._valid_diff_filter = ("m", "a", "d") + self._env = os.environ.copy() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + pass + + def _run(self, *args, **runargs): + return_codes = runargs.get("return_codes", []) + + cmd = (str(self._tool),) + args + try: + return subprocess.check_output( + cmd, cwd=self.path, env=self._env, universal_newlines=True + ) + except subprocess.CalledProcessError as e: + if e.returncode in return_codes: + return "" + raise + + @property + def tool_version(self): + """Return the version of the VCS tool in use as a string.""" + if self._version: + return self._version + info = self._run("--version").strip() + match = re.search("version ([^\+\)]+)", info) + if not match: + raise Exception("Unable to identify tool version.") + + self.version = match.group(1) + return self.version + + @property + def has_git_cinnabar(self): + """True if the repository is using git cinnabar.""" + return False + + @abc.abstractproperty + def name(self): + """Name of the tool.""" + + @abc.abstractproperty + def head_ref(self): + """Hash of HEAD revision.""" + + @abc.abstractproperty + def base_ref(self): + """Hash of revision the current topic branch is based on.""" + + @abc.abstractmethod + def base_ref_as_hg(self): + """Mercurial hash of revision the current topic branch is based on. + + Return None if the hg hash of the base ref could not be calculated. + """ + + @abc.abstractproperty + def branch(self): + """Current branch or bookmark the checkout has active.""" + + @abc.abstractmethod + def get_commit_time(self): + """Return the Unix time of the HEAD revision.""" + + @abc.abstractmethod + def sparse_checkout_present(self): + """Whether the working directory is using a sparse checkout. + + A sparse checkout is defined as a working directory that only + materializes a subset of files in a given revision. + + Returns a bool. + """ + + @abc.abstractmethod + def get_user_email(self): + """Return the user's email address. + + If no email is configured, then None is returned. + """ + + @abc.abstractmethod + def get_upstream(self): + """Reference to the upstream remote.""" + + @abc.abstractmethod + def get_changed_files(self, diff_filter, mode="unstaged", rev=None): + """Return a list of files that are changed in this repository's + working copy. + + ``diff_filter`` controls which kinds of modifications are returned. + It is a string which may only contain the following characters: + + A - Include files that were added + D - Include files that were deleted + M - Include files that were modified + + By default, all three will be included. + + ``mode`` can be one of 'unstaged', 'staged' or 'all'. Only has an + effect on git. Defaults to 'unstaged'. + + ``rev`` is a specifier for which changesets to consider for + changes. The exact meaning depends on the vcs system being used. + """ + + @abc.abstractmethod + def get_outgoing_files(self, diff_filter, upstream): + """Return a list of changed files compared to upstream. + + ``diff_filter`` works the same as `get_changed_files`. + ``upstream`` is a remote ref to compare against. If unspecified, + this will be determined automatically. If there is no remote ref, + a MissingUpstreamRepo exception will be raised. + """ + + @abc.abstractmethod + def add_remove_files(self, *paths: Union[str, Path]): + """Add and remove files under `paths` in this repository's working copy.""" + + @abc.abstractmethod + def forget_add_remove_files(self, *paths: Union[str, Path]): + """Undo the effects of a previous add_remove_files call for `paths`.""" + + @abc.abstractmethod + def get_tracked_files_finder(self): + """Obtain a mozpack.files.BaseFinder of managed files in the working + directory. + + The Finder will have its list of all files in the repo cached for its + entire lifetime, so operations on the Finder will not track with, for + example, commits to the repo during the Finder's lifetime. + """ + + @abc.abstractmethod + def get_ignored_files_finder(self): + """Obtain a mozpack.files.BaseFinder of ignored files in the working + directory. + + The Finder will have its list of all files in the repo cached for its + entire lifetime, so operations on the Finder will not track with, for + example, changes to the repo during the Finder's lifetime. + """ + + @abc.abstractmethod + def working_directory_clean(self, untracked=False, ignored=False): + """Determine if the working directory is free of modifications. + + Returns True if the working directory does not have any file + modifications. False otherwise. + + By default, untracked and ignored files are not considered. If + ``untracked`` or ``ignored`` are set, they influence the clean check + to factor these file classes into consideration. + """ + + @abc.abstractmethod + def clean_directory(self, path: Union[str, Path]): + """Undo all changes (including removing new untracked files) in the + given `path`. + """ + + @abc.abstractmethod + def push_to_try(self, message, allow_log_capture=False): + """Create a temporary commit, push it to try and clean it up + afterwards. + + With mercurial, MissingVCSExtension will be raised if the `push-to-try` + extension is not installed. On git, MissingVCSExtension will be raised + if git cinnabar is not present. + + If `allow_log_capture` is set to `True`, then the push-to-try will be run using + Popen instead of check_call so that the logs can be captured elsewhere. + """ + + @abc.abstractmethod + def update(self, ref): + """Update the working directory to the specified reference.""" + + def commit(self, message, author=None, date=None, paths=None): + """Create a commit using the provided commit message. The author, date, + and files/paths to be included may also be optionally provided. The + message, author and date arguments must be strings, and are passed as-is + to the commit command. Multiline commit messages are supported. The + paths argument must be None or an array of strings that represents the + set of files and folders to include in the commit. + """ + args = ["commit", "-m", message] + if author is not None: + if isinstance(self, HgRepository): + args = args + ["--user", author] + elif isinstance(self, GitRepository): + args = args + ["--author", author] + else: + raise MissingVCSInfo("Unknown repo type") + if date is not None: + args = args + ["--date", date] + if paths is not None: + args = args + paths + self._run(*args) + + def _push_to_try_with_log_capture(self, cmd, subprocess_opts): + """Push to try but with the ability for the user to capture logs. + + We need to use Popen for this because neither the run method nor + check_call will allow us to reasonably catch the logs. With check_call, + hg hangs, and with the run method, the logs are output too slowly + so you're left wondering if it's working (prime candidate for + corrupting local repos). + """ + process = subprocess.Popen(cmd, **subprocess_opts) + + # Print out the lines as they appear so they can be + # parsed for information + for line in process.stdout or []: + print(line) + process.stdout.close() + process.wait() + + if process.returncode != 0: + for line in process.stderr or []: + print(line) + raise subprocess.CalledProcessError("Failed to push-to-try") + + +class HgRepository(Repository): + """An implementation of `Repository` for Mercurial repositories.""" + + def __init__(self, path: Path, hg="hg"): + import hglib.client + + super(HgRepository, self).__init__(path, tool=hg) + self._env["HGPLAIN"] = "1" + + # Setting this modifies a global variable and makes all future hglib + # instances use this binary. Since the tool path was validated, this + # should be OK. But ideally hglib would offer an API that defines + # per-instance binaries. + hglib.HGPATH = str(self._tool) + + # Without connect=False this spawns a persistent process. We want + # the process lifetime tied to a context manager. + self._client = hglib.client.hgclient( + self.path, encoding="UTF-8", configs=None, connect=False + ) + + @property + def name(self): + return "hg" + + @property + def head_ref(self): + return self._run("log", "-r", ".", "-T", "{node}") + + @property + def base_ref(self): + return self._run("log", "-r", "last(ancestors(.) and public())", "-T", "{node}") + + def base_ref_as_hg(self): + return self.base_ref + + @property + def branch(self): + bookmarks_fn = Path(self.path) / ".hg" / "bookmarks.current" + if bookmarks_fn.exists(): + with open(bookmarks_fn) as f: + bookmark = f.read() + return bookmark or None + + return None + + def __enter__(self): + if self._client.server is None: + # The cwd if the spawned process should be the repo root to ensure + # relative paths are normalized to it. + old_cwd = Path.cwd() + try: + os.chdir(self.path) + self._client.open() + finally: + os.chdir(old_cwd) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._client.close() + + def _run(self, *args, **runargs): + if not self._client.server: + return super(HgRepository, self)._run(*args, **runargs) + + # hglib requires bytes on python 3 + args = [a.encode("utf-8") if not isinstance(a, bytes) else a for a in args] + return self._client.rawcommand(args).decode("utf-8") + + def get_commit_time(self): + newest_public_revision_time = self._run( + "log", + "--rev", + "heads(ancestors(.) and not draft())", + "--template", + "{word(0, date|hgdate)}", + "--limit", + "1", + ).strip() + + if not newest_public_revision_time: + raise RuntimeError( + "Unable to find a non-draft commit in this hg " + "repository. If you created this repository from a " + 'bundle, have you done a "hg pull" from hg.mozilla.org ' + "since?" + ) + + return int(newest_public_revision_time) + + def sparse_checkout_present(self): + # We assume a sparse checkout is enabled if the .hg/sparse file + # has data. Strictly speaking, we should look for a requirement in + # .hg/requires. But since the requirement is still experimental + # as of Mercurial 4.3, it's probably more trouble than its worth + # to verify it. + sparse = Path(self.path) / ".hg" / "sparse" + + try: + st = sparse.stat() + return st.st_size > 0 + except OSError as e: + if e.errno != errno.ENOENT: + raise + + return False + + def get_user_email(self): + # Output is in the form "First Last <flast@mozilla.com>" + username = self._run("config", "ui.username", return_codes=[0, 1]) + if not username: + # No username is set + return None + match = re.search(r"<(.*)>", username) + if not match: + # "ui.username" doesn't follow the "Full Name <email@domain>" convention + return None + return match.group(1) + + def get_upstream(self): + return "default" + + def _format_diff_filter(self, diff_filter, for_status=False): + df = diff_filter.lower() + assert all(f in self._valid_diff_filter for f in df) + + # When looking at the changes in the working directory, the hg status + # command uses 'd' for files that have been deleted with a non-hg + # command, and 'r' for files that have been `hg rm`ed. Use both. + return df.replace("d", "dr") if for_status else df + + def _files_template(self, diff_filter): + template = "" + df = self._format_diff_filter(diff_filter) + if "a" in df: + template += "{file_adds % '{file}\\n'}" + if "d" in df: + template += "{file_dels % '{file}\\n'}" + if "m" in df: + template += "{file_mods % '{file}\\n'}" + return template + + def get_changed_files(self, diff_filter="ADM", mode="unstaged", rev=None): + if rev is None: + # Use --no-status to print just the filename. + df = self._format_diff_filter(diff_filter, for_status=True) + return self._run("status", "--no-status", "-{}".format(df)).splitlines() + else: + template = self._files_template(diff_filter) + return self._run("log", "-r", rev, "-T", template).splitlines() + + def get_outgoing_files(self, diff_filter="ADM", upstream=None): + template = self._files_template(diff_filter) + + if not upstream: + return self._run( + "log", "-r", "draft() and ancestors(.)", "--template", template + ).split() + + return self._run( + "outgoing", + "-r", + ".", + "--quiet", + "--template", + template, + upstream, + return_codes=(1,), + ).split() + + def add_remove_files(self, *paths: Union[str, Path]): + if not paths: + return + + paths = [str(path) for path in paths] + + args = ["addremove"] + paths + m = re.search(r"\d+\.\d+", self.tool_version) + simplified_version = float(m.group(0)) if m else 0 + if simplified_version >= 3.9: + args = ["--config", "extensions.automv="] + args + self._run(*args) + + def forget_add_remove_files(self, *paths: Union[str, Path]): + if not paths: + return + + paths = [str(path) for path in paths] + + self._run("forget", *paths) + + def get_tracked_files_finder(self): + # Can return backslashes on Windows. Normalize to forward slashes. + files = list( + p.replace("\\", "/") for p in self._run("files", "-0").split("\0") if p + ) + return FileListFinder(files) + + def get_ignored_files_finder(self): + # Can return backslashes on Windows. Normalize to forward slashes. + files = list( + p.replace("\\", "/").split(" ")[-1] + for p in self._run("status", "-i").split("\n") + if p + ) + return FileListFinder(files) + + def working_directory_clean(self, untracked=False, ignored=False): + args = ["status", "--modified", "--added", "--removed", "--deleted"] + if untracked: + args.append("--unknown") + if ignored: + args.append("--ignored") + + # If output is empty, there are no entries of requested status, which + # means we are clean. + return not len(self._run(*args).strip()) + + def clean_directory(self, path: Union[str, Path]): + if Path(self.path).samefile(path): + raise CannotDeleteFromRootOfRepositoryException() + self._run("revert", str(path)) + for single_path in self._run("st", "-un", str(path)).splitlines(): + single_path = Path(single_path) + if single_path.is_file(): + single_path.unlink() + else: + shutil.rmtree(str(single_path)) + + def update(self, ref): + return self._run("update", "--check", ref) + + def push_to_try(self, message, allow_log_capture=False): + try: + cmd = (str(self._tool), "push-to-try", "-m", message) + if allow_log_capture: + self._push_to_try_with_log_capture( + cmd, + { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "cwd": self.path, + "env": self._env, + "universal_newlines": True, + "bufsize": 1, + }, + ) + else: + subprocess.check_call( + cmd, + cwd=self.path, + env=self._env, + ) + except subprocess.CalledProcessError: + try: + self._run("showconfig", "extensions.push-to-try") + except subprocess.CalledProcessError: + raise MissingVCSExtension("push-to-try") + raise + finally: + self._run("revert", "-a") + + +class GitRepository(Repository): + """An implementation of `Repository` for Git repositories.""" + + def __init__(self, path: Path, git="git"): + super(GitRepository, self).__init__(path, tool=git) + + @property + def name(self): + return "git" + + @property + def head_ref(self): + return self._run("rev-parse", "HEAD").strip() + + @property + def base_ref(self): + refs = self._run( + "rev-list", "HEAD", "--topo-order", "--boundary", "--not", "--remotes" + ).splitlines() + if refs: + return refs[-1][1:] # boundary starts with a prefix `-` + return self.head_ref + + def base_ref_as_hg(self): + base_ref = self.base_ref + try: + return self._run("cinnabar", "git2hg", base_ref) + except subprocess.CalledProcessError: + return + + @property + def branch(self): + return self._run("branch", "--show-current").strip() or None + + @property + def has_git_cinnabar(self): + try: + self._run("cinnabar", "--version") + except subprocess.CalledProcessError: + return False + return True + + def get_commit_time(self): + return int(self._run("log", "-1", "--format=%ct").strip()) + + def sparse_checkout_present(self): + # Not yet implemented. + return False + + def get_user_email(self): + email = self._run("config", "user.email", return_codes=[0, 1]) + if not email: + return None + return email.strip() + + def get_upstream(self): + ref = self._run("symbolic-ref", "-q", "HEAD").strip() + upstream = self._run("for-each-ref", "--format=%(upstream:short)", ref).strip() + + if not upstream: + raise MissingUpstreamRepo("Could not detect an upstream repository.") + + return upstream + + def get_changed_files(self, diff_filter="ADM", mode="unstaged", rev=None): + assert all(f.lower() in self._valid_diff_filter for f in diff_filter) + + if rev is None: + cmd = ["diff"] + if mode == "staged": + cmd.append("--cached") + elif mode == "all": + cmd.append("HEAD") + else: + cmd = ["diff-tree", "-r", "--no-commit-id", rev] + + cmd.append("--name-only") + cmd.append("--diff-filter=" + diff_filter.upper()) + + return self._run(*cmd).splitlines() + + def get_outgoing_files(self, diff_filter="ADM", upstream=None): + assert all(f.lower() in self._valid_diff_filter for f in diff_filter) + + not_condition = upstream if upstream else "--remotes" + + files = self._run( + "log", + "--name-only", + "--diff-filter={}".format(diff_filter.upper()), + "--oneline", + "--pretty=format:", + "HEAD", + "--not", + not_condition, + ).splitlines() + return [f for f in files if f] + + def add_remove_files(self, *paths: Union[str, Path]): + if not paths: + return + + paths = [str(path) for path in paths] + + self._run("add", *paths) + + def forget_add_remove_files(self, *paths: Union[str, Path]): + if not paths: + return + + paths = [str(path) for path in paths] + + self._run("reset", *paths) + + def get_tracked_files_finder(self): + files = [p for p in self._run("ls-files", "-z").split("\0") if p] + return FileListFinder(files) + + def get_ignored_files_finder(self): + files = [ + p + for p in self._run( + "ls-files", "-i", "-o", "-z", "--exclude-standard" + ).split("\0") + if p + ] + return FileListFinder(files) + + def working_directory_clean(self, untracked=False, ignored=False): + args = ["status", "--porcelain"] + + # Even in --porcelain mode, behavior is affected by the + # ``status.showUntrackedFiles`` option, which means we need to be + # explicit about how to treat untracked files. + if untracked: + args.append("--untracked-files=all") + else: + args.append("--untracked-files=no") + + if ignored: + args.append("--ignored") + + return not len(self._run(*args).strip()) + + def clean_directory(self, path: Union[str, Path]): + if Path(self.path).samefile(path): + raise CannotDeleteFromRootOfRepositoryException() + self._run("checkout", "--", str(path)) + self._run("clean", "-df", str(path)) + + def update(self, ref): + self._run("checkout", ref) + + def push_to_try(self, message, allow_log_capture=False): + if not self.has_git_cinnabar: + raise MissingVCSExtension("cinnabar") + + self._run( + "-c", "commit.gpgSign=false", "commit", "--allow-empty", "-m", message + ) + try: + cmd = ( + str(self._tool), + "push", + "hg::ssh://hg.mozilla.org/try", + "+HEAD:refs/heads/branches/default/tip", + ) + if allow_log_capture: + self._push_to_try_with_log_capture( + cmd, + { + "stdout": subprocess.PIPE, + "cwd": self.path, + "universal_newlines": True, + "bufsize": 1, + }, + ) + else: + subprocess.check_call(cmd, cwd=self.path) + finally: + self._run("reset", "HEAD~") + + def set_config(self, name, value): + self._run("config", name, value) + + +def get_repository_object(path: Optional[Union[str, Path]], hg="hg", git="git"): + """Get a repository object for the repository at `path`. + If `path` is not a known VCS repository, raise an exception. + """ + # If we provide a path to hg that does not match the on-disk casing (e.g., + # because `path` was normcased), then the hg fsmonitor extension will call + # watchman with that path and watchman will spew errors. + path = Path(path).resolve() + if (path / ".hg").is_dir(): + return HgRepository(path, hg=hg) + elif (path / ".git").exists(): + return GitRepository(path, git=git) + else: + raise InvalidRepoPath(f"Unknown VCS, or not a source checkout: {path}") + + +def get_repository_from_build_config(config): + """Obtain a repository from the build configuration. + + Accepts an object that has a ``topsrcdir`` and ``subst`` attribute. + """ + flavor = config.substs.get("VCS_CHECKOUT_TYPE") + + # If in build mode, only use what configure found. That way we ensure + # that everything in the build system can be controlled via configure. + if not flavor: + raise MissingConfigureInfo( + "could not find VCS_CHECKOUT_TYPE " + "in build config; check configure " + "output and verify it could find a " + "VCS binary" + ) + + if flavor == "hg": + return HgRepository(Path(config.topsrcdir), hg=config.substs["HG"]) + elif flavor == "git": + return GitRepository(Path(config.topsrcdir), git=config.substs["GIT"]) + else: + raise MissingVCSInfo("unknown VCS_CHECKOUT_TYPE value: %s" % flavor) + + +def get_repository_from_env(): + """Obtain a repository object by looking at the environment. + + If inside a build environment (denoted by presence of a ``buildconfig`` + module), VCS info is obtained from it, as found via configure. This allows + us to respect what was passed into configure. Otherwise, we fall back to + scanning the filesystem. + """ + try: + import buildconfig + + return get_repository_from_build_config(buildconfig) + except (ImportError, MissingVCSTool): + pass + + paths_to_check = [Path.cwd(), *Path.cwd().parents] + + for path in paths_to_check: + try: + return get_repository_object(path) + except InvalidRepoPath: + continue + + raise MissingVCSInfo(f"Could not find Mercurial or Git checkout for {Path.cwd()}") diff --git a/python/mozversioncontrol/mozversioncontrol/repoupdate.py b/python/mozversioncontrol/mozversioncontrol/repoupdate.py new file mode 100644 index 0000000000..5336263794 --- /dev/null +++ b/python/mozversioncontrol/mozversioncontrol/repoupdate.py @@ -0,0 +1,37 @@ +# 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/. + +import subprocess +from pathlib import Path +from typing import Union + +# The logic here is far from robust. Improvements are welcome. + + +def update_mercurial_repo( + hg: str, + repo, + path: Union[str, Path], + revision="default", + hostfingerprints=None, + global_args=None, +): + """Ensure a HG repository exists at a path and is up to date.""" + hostfingerprints = hostfingerprints or {} + + path = Path(path) + + args = [hg] + if global_args: + args.extend(global_args) + + for host, fingerprint in sorted(hostfingerprints.items()): + args.extend(["--config", "hostfingerprints.%s=%s" % (host, fingerprint)]) + + if path.exists(): + subprocess.check_call(args + ["pull", repo], cwd=str(path)) + else: + subprocess.check_call(args + ["clone", repo, str(path)]) + + subprocess.check_call([hg, "update", "-r", revision], cwd=str(path)) diff --git a/python/mozversioncontrol/setup.py b/python/mozversioncontrol/setup.py new file mode 100644 index 0000000000..c0e1aa643f --- /dev/null +++ b/python/mozversioncontrol/setup.py @@ -0,0 +1,28 @@ +# 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/. + +from setuptools import find_packages, setup + +VERSION = "0.1" + +setup( + author="Mozilla Foundation", + author_email="Mozilla Release Engineering", + name="mozversioncontrol", + description="Mozilla version control functionality", + license="MPL 2.0", + packages=find_packages(), + version=VERSION, + classifiers=[ + "Development Status :: 3 - Alpha", + "Topic :: Software Development :: Build Tools", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: Implementation :: CPython", + ], + keywords="mozilla", +) diff --git a/python/mozversioncontrol/test/conftest.py b/python/mozversioncontrol/test/conftest.py new file mode 100644 index 0000000000..78e5ad7ca8 --- /dev/null +++ b/python/mozversioncontrol/test/conftest.py @@ -0,0 +1,84 @@ +# 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/. + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +SETUP = { + "hg": [ + """ + echo "foo" > foo + echo "bar" > bar + hg init + hg add * + hg commit -m "Initial commit" + hg phase --public . + """, + """ + echo [paths] > .hg/hgrc + echo "default = ../remoterepo" >> .hg/hgrc + """, + ], + "git": [ + """ + echo "foo" > foo + echo "bar" > bar + git init + git config user.name "Testing McTesterson" + git config user.email "<test@example.org>" + git add * + git commit -am "Initial commit" + """, + """ + git remote add upstream ../remoterepo + git fetch upstream + git branch -u upstream/master + """, + ], +} + + +class RepoTestFixture: + def __init__(self, repo_dir: Path, vcs: str, steps: [str]): + self.dir = repo_dir + self.vcs = vcs + + # This creates a step iterator. Each time execute_next_step() + # is called the next set of instructions will be executed. + self.steps = (shell(cmd, self.dir) for cmd in steps) + + def execute_next_step(self): + next(self.steps) + + +def shell(cmd, working_dir): + for step in cmd.split(os.linesep): + subprocess.check_call(step, shell=True, cwd=working_dir) + + +@pytest.fixture(params=["git", "hg"]) +def repo(tmpdir, request): + tmpdir = Path(tmpdir) + vcs = request.param + steps = SETUP[vcs] + + if hasattr(request.module, "STEPS"): + steps.extend(request.module.STEPS[vcs]) + + repo_dir = (tmpdir / "repo").resolve() + (tmpdir / "repo").mkdir() + + repo_test_fixture = RepoTestFixture(repo_dir, vcs, steps) + + repo_test_fixture.execute_next_step() + + shutil.copytree(str(repo_dir), str(tmpdir / "remoterepo")) + + repo_test_fixture.execute_next_step() + + yield repo_test_fixture diff --git a/python/mozversioncontrol/test/python.ini b/python/mozversioncontrol/test/python.ini new file mode 100644 index 0000000000..79e52bf937 --- /dev/null +++ b/python/mozversioncontrol/test/python.ini @@ -0,0 +1,10 @@ +[DEFAULT] +subsuite=mozversioncontrol + +[test_branch.py] +[test_commit.py] +[test_context_manager.py] +[test_push_to_try.py] +[test_update.py] +[test_workdir_outgoing.py] +[test_working_directory.py] diff --git a/python/mozversioncontrol/test/test_branch.py b/python/mozversioncontrol/test/test_branch.py new file mode 100644 index 0000000000..7d211f18e8 --- /dev/null +++ b/python/mozversioncontrol/test/test_branch.py @@ -0,0 +1,57 @@ +# 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/. + +import mozunit +import pytest +from looseversion import LooseVersion + +from mozversioncontrol import get_repository_object + +STEPS = { + "hg": [ + """ + hg bookmark test + """, + """ + echo "bar" > foo + hg commit -m "second commit" + """, + ], + "git": [ + """ + git checkout -b test + """, + """ + echo "bar" > foo + git commit -a -m "second commit" + """, + ], +} + + +def test_branch(repo): + vcs = get_repository_object(repo.dir) + if vcs.name == "git" and LooseVersion(vcs.tool_version) < LooseVersion("2.22.0"): + pytest.xfail("`git branch --show-current` not implemented yet") + + if vcs.name == "git": + assert vcs.branch == "master" + else: + assert vcs.branch is None + + repo.execute_next_step() + assert vcs.branch == "test" + + repo.execute_next_step() + assert vcs.branch == "test" + + vcs.update(vcs.head_ref) + assert vcs.branch is None + + vcs.update("test") + assert vcs.branch == "test" + + +if __name__ == "__main__": + mozunit.main() diff --git a/python/mozversioncontrol/test/test_commit.py b/python/mozversioncontrol/test/test_commit.py new file mode 100644 index 0000000000..b795c0ea6e --- /dev/null +++ b/python/mozversioncontrol/test/test_commit.py @@ -0,0 +1,72 @@ +# 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/. + +import mozunit + +from mozversioncontrol import get_repository_object + +STEPS = { + "hg": [ + """ + echo "bar" >> bar + echo "baz" > foo + """, + ], + "git": [ + """ + echo "bar" >> bar + echo "baz" > foo + """, + ], +} + + +def test_commit(repo): + vcs = get_repository_object(repo.dir) + assert vcs.working_directory_clean() + + # Modify both foo and bar + repo.execute_next_step() + assert not vcs.working_directory_clean() + + # Commit just bar + vcs.commit( + message="Modify bar\n\nbut not baz", + author="Testing McTesterson <test@example.org>", + date="2017-07-14 02:40:00 UTC", + paths=["bar"], + ) + + # We only committed bar, so foo is still keeping the working dir dirty + assert not vcs.working_directory_clean() + + if repo.vcs == "git": + log_cmd = ["log", "-1", "--format=%an,%ae,%aD,%B"] + patch_cmd = ["log", "-1", "-p"] + else: + log_cmd = [ + "log", + "-l", + "1", + "-T", + "{person(author)},{email(author)},{date|rfc822date},{desc}", + ] + patch_cmd = ["log", "-l", "1", "-p"] + + # Verify commit metadata (we rstrip to normalize trivial git/hg differences) + log = vcs._run(*log_cmd).rstrip() + assert log == ( + "Testing McTesterson,test@example.org,Fri, 14 " + "Jul 2017 02:40:00 +0000,Modify bar\n\nbut not baz" + ) + + # Verify only the intended file was added to the commit + patch = vcs._run(*patch_cmd) + diffs = [line for line in patch.splitlines() if "diff --git" in line] + assert len(diffs) == 1 + assert diffs[0] == "diff --git a/bar b/bar" + + +if __name__ == "__main__": + mozunit.main() diff --git a/python/mozversioncontrol/test/test_context_manager.py b/python/mozversioncontrol/test/test_context_manager.py new file mode 100644 index 0000000000..3186a144d9 --- /dev/null +++ b/python/mozversioncontrol/test/test_context_manager.py @@ -0,0 +1,28 @@ +# 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/. + +import mozunit + +from mozversioncontrol import get_repository_object + + +def test_context_manager(repo): + is_git = repo.vcs == "git" + cmd = ["show", "--no-patch"] if is_git else ["tip"] + + vcs = get_repository_object(repo.dir) + output_subprocess = vcs._run(*cmd) + assert is_git or vcs._client.server is None + assert "Initial commit" in output_subprocess + + with vcs: + assert is_git or vcs._client.server is not None + output_client = vcs._run(*cmd) + + assert is_git or vcs._client.server is None + assert output_subprocess == output_client + + +if __name__ == "__main__": + mozunit.main() diff --git a/python/mozversioncontrol/test/test_push_to_try.py b/python/mozversioncontrol/test/test_push_to_try.py new file mode 100644 index 0000000000..d0a0b2d993 --- /dev/null +++ b/python/mozversioncontrol/test/test_push_to_try.py @@ -0,0 +1,81 @@ +# 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/. + +import subprocess + +import mozunit +import pytest + +from mozversioncontrol import MissingVCSExtension, get_repository_object + + +def test_push_to_try(repo, monkeypatch): + commit_message = "commit message" + vcs = get_repository_object(repo.dir) + + captured_commands = [] + + def fake_run(*args, **kwargs): + captured_commands.append(args[0]) + + monkeypatch.setattr(subprocess, "check_output", fake_run) + monkeypatch.setattr(subprocess, "check_call", fake_run) + + vcs.push_to_try(commit_message) + tool = vcs._tool + + if repo.vcs == "hg": + expected = [ + (str(tool), "push-to-try", "-m", commit_message), + (str(tool), "revert", "-a"), + ] + else: + expected = [ + (str(tool), "cinnabar", "--version"), + ( + str(tool), + "-c", + "commit.gpgSign=false", + "commit", + "--allow-empty", + "-m", + commit_message, + ), + ( + str(tool), + "push", + "hg::ssh://hg.mozilla.org/try", + "+HEAD:refs/heads/branches/default/tip", + ), + (str(tool), "reset", "HEAD~"), + ] + + for i, value in enumerate(captured_commands): + assert value == expected[i] + + assert len(captured_commands) == len(expected) + + +def test_push_to_try_missing_extensions(repo, monkeypatch): + if repo.vcs != "git": + return + + vcs = get_repository_object(repo.dir) + + orig = vcs._run + + def cinnabar_raises(*args, **kwargs): + # Simulate not having git cinnabar + if args[0] == "cinnabar": + raise subprocess.CalledProcessError(1, args) + return orig(*args, **kwargs) + + monkeypatch.setattr(vcs, "_run", cinnabar_raises) + + with pytest.raises(MissingVCSExtension): + vcs.push_to_try("commit message") + + +if __name__ == "__main__": + mozunit.main() diff --git a/python/mozversioncontrol/test/test_update.py b/python/mozversioncontrol/test/test_update.py new file mode 100644 index 0000000000..91c7469ee5 --- /dev/null +++ b/python/mozversioncontrol/test/test_update.py @@ -0,0 +1,63 @@ +# 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/. + +from subprocess import CalledProcessError + +import mozunit +import pytest + +from mozversioncontrol import get_repository_object + +STEPS = { + "hg": [ + """ + echo "bar" >> bar + echo "baz" > foo + hg commit -m "second commit" + """, + """ + echo "foobar" > foo + """, + ], + "git": [ + """ + echo "bar" >> bar + echo "baz" > foo + git add * + git commit -m "second commit" + """, + """ + echo "foobar" > foo + """, + ], +} + + +def test_update(repo): + vcs = get_repository_object(repo.dir) + rev0 = vcs.head_ref + + repo.execute_next_step() + rev1 = vcs.head_ref + assert rev0 != rev1 + + if repo.vcs == "hg": + vcs.update(".~1") + else: + vcs.update("HEAD~1") + assert vcs.head_ref == rev0 + + vcs.update(rev1) + assert vcs.head_ref == rev1 + + # Update should fail with dirty working directory. + repo.execute_next_step() + with pytest.raises(CalledProcessError): + vcs.update(rev0) + + assert vcs.head_ref == rev1 + + +if __name__ == "__main__": + mozunit.main() diff --git a/python/mozversioncontrol/test/test_workdir_outgoing.py b/python/mozversioncontrol/test/test_workdir_outgoing.py new file mode 100644 index 0000000000..7bf2e6ec57 --- /dev/null +++ b/python/mozversioncontrol/test/test_workdir_outgoing.py @@ -0,0 +1,108 @@ +# 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/. + +import os + +import mozunit + +from mozversioncontrol import get_repository_object + +STEPS = { + "hg": [ + """ + echo "bar" >> bar + echo "baz" > baz + hg add baz + hg rm foo + """, + """ + hg commit -m "Remove foo; modify bar; add baz" + """, + """ + echo ooka >> baz + echo newborn > baby + hg add baby + """, + """ + hg commit -m "Modify baz; add baby" + """, + ], + "git": [ + """ + echo "bar" >> bar + echo "baz" > baz + git add baz + git rm foo + """, + """ + git commit -am "Remove foo; modify bar; add baz" + """, + """ + echo ooka >> baz + echo newborn > baby + git add baz baby + """, + """ + git commit -m "Modify baz; add baby" + """, + ], +} + + +def assert_files(actual, expected): + assert set(map(os.path.basename, actual)) == set(expected) + + +def test_workdir_outgoing(repo): + vcs = get_repository_object(repo.dir) + assert vcs.path == str(repo.dir) + + remote_path = "../remoterepo" if repo.vcs == "hg" else "upstream/master" + + # Mutate files. + repo.execute_next_step() + + assert_files(vcs.get_changed_files("A", "all"), ["baz"]) + assert_files(vcs.get_changed_files("AM", "all"), ["bar", "baz"]) + assert_files(vcs.get_changed_files("D", "all"), ["foo"]) + if repo.vcs == "git": + assert_files(vcs.get_changed_files("AM", mode="staged"), ["baz"]) + elif repo.vcs == "hg": + # Mercurial does not use a staging area (and ignores the mode parameter.) + assert_files(vcs.get_changed_files("AM", "unstaged"), ["bar", "baz"]) + assert_files(vcs.get_outgoing_files("AMD"), []) + assert_files(vcs.get_outgoing_files("AMD", remote_path), []) + + # Create a commit. + repo.execute_next_step() + + assert_files(vcs.get_changed_files("AMD", "all"), []) + assert_files(vcs.get_changed_files("AMD", "staged"), []) + assert_files(vcs.get_outgoing_files("AMD"), ["bar", "baz", "foo"]) + assert_files(vcs.get_outgoing_files("AMD", remote_path), ["bar", "baz", "foo"]) + + # Mutate again. + repo.execute_next_step() + + assert_files(vcs.get_changed_files("A", "all"), ["baby"]) + assert_files(vcs.get_changed_files("AM", "all"), ["baby", "baz"]) + assert_files(vcs.get_changed_files("D", "all"), []) + + # Create a second commit. + repo.execute_next_step() + + assert_files(vcs.get_outgoing_files("AM"), ["bar", "baz", "baby"]) + assert_files(vcs.get_outgoing_files("AM", remote_path), ["bar", "baz", "baby"]) + if repo.vcs == "git": + assert_files(vcs.get_changed_files("AM", rev="HEAD~1"), ["bar", "baz"]) + assert_files(vcs.get_changed_files("AM", rev="HEAD"), ["baby", "baz"]) + else: + assert_files(vcs.get_changed_files("AM", rev=".^"), ["bar", "baz"]) + assert_files(vcs.get_changed_files("AM", rev="."), ["baby", "baz"]) + assert_files(vcs.get_changed_files("AM", rev=".^::"), ["bar", "baz", "baby"]) + assert_files(vcs.get_changed_files("AM", rev="modifies(baz)"), ["baz", "baby"]) + + +if __name__ == "__main__": + mozunit.main() diff --git a/python/mozversioncontrol/test/test_working_directory.py b/python/mozversioncontrol/test/test_working_directory.py new file mode 100644 index 0000000000..00094a0cc4 --- /dev/null +++ b/python/mozversioncontrol/test/test_working_directory.py @@ -0,0 +1,46 @@ +# 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/. + +import mozunit + +from mozversioncontrol import get_repository_object + +STEPS = { + "hg": [ + """ + echo "bar" >> bar + echo "baz" > baz + hg rm foo + """, + """ + hg commit -m "Remove foo; modify bar; touch baz (but don't add it)" + """, + ], + "git": [ + """ + echo "bar" >> bar + echo "baz" > baz + git rm foo + """, + """ + git commit -am "Remove foo; modify bar; touch baz (but don't add it)" + """, + ], +} + + +def test_working_directory_clean_untracked_files(repo): + vcs = get_repository_object(repo.dir) + assert vcs.working_directory_clean() + + repo.execute_next_step() + assert not vcs.working_directory_clean() + + repo.execute_next_step() + assert vcs.working_directory_clean() + assert not vcs.working_directory_clean(untracked=True) + + +if __name__ == "__main__": + mozunit.main() |