summaryrefslogtreecommitdiffstats
path: root/python/mozversioncontrol
diff options
context:
space:
mode:
Diffstat (limited to 'python/mozversioncontrol')
-rw-r--r--python/mozversioncontrol/mozversioncontrol/__init__.py711
-rw-r--r--python/mozversioncontrol/mozversioncontrol/repoupdate.py41
-rw-r--r--python/mozversioncontrol/setup.py30
-rw-r--r--python/mozversioncontrol/test/conftest.py74
-rw-r--r--python/mozversioncontrol/test/python.ini8
-rw-r--r--python/mozversioncontrol/test/test_commit.py74
-rw-r--r--python/mozversioncontrol/test/test_context_manager.py30
-rw-r--r--python/mozversioncontrol/test/test_push_to_try.py86
-rw-r--r--python/mozversioncontrol/test/test_workdir_outgoing.py111
-rw-r--r--python/mozversioncontrol/test/test_working_directory.py48
10 files changed, 1213 insertions, 0 deletions
diff --git a/python/mozversioncontrol/mozversioncontrol/__init__.py b/python/mozversioncontrol/mozversioncontrol/__init__.py
new file mode 100644
index 0000000000..6d58c622ca
--- /dev/null
+++ b/python/mozversioncontrol/mozversioncontrol/__init__.py
@@ -0,0 +1,711 @@
+# 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 __future__ import absolute_import, print_function, unicode_literals
+
+import abc
+import errno
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+from mozbuild.util import ensure_subprocess_env
+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):
+ """Obtain the path of `tool`."""
+ if os.path.isabs(tool) and os.path.exists(tool):
+ return tool
+
+ path = which(tool)
+ if not path:
+ raise MissingVCSTool(
+ "Unable to obtain %s path. Try running "
+ "|mach bootstrap| to ensure your environment is up to "
+ "date." % tool
+ )
+ return path
+
+
+def _paths_equal(a, b):
+ """Return True iff the two paths refer to the "same" file on disk."""
+ return os.path.normpath(os.path.realpath(a)) == os.path.normpath(
+ os.path.realpath(b)
+ )
+
+
+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, tool):
+ self.path = os.path.abspath(path)
+ self._tool = get_tool_path(tool)
+ self._version = None
+ self._valid_diff_filter = ("m", "a", "d")
+
+ if os.name == "nt" and sys.version_info[0] == 2:
+ self._env = {}
+ for k, v in os.environ.iteritems():
+ if isinstance(k, unicode):
+ k = k.encode("utf8")
+ if isinstance(v, unicode):
+ v = v.encode("utf8")
+ self._env[k] = v
+ else:
+ 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 = (self._tool,) + args
+ try:
+ return subprocess.check_output(
+ cmd,
+ cwd=self.path,
+ env=ensure_subprocess_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 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="default"):
+ """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):
+ """Add and remove files under `paths` in this repository's working copy."""
+
+ @abc.abstractmethod
+ def forget_add_remove_files(self, *paths):
+ """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 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):
+ """Undo all changes (including removing new untracked files) in the
+ given `path`.
+ """
+
+ @abc.abstractmethod
+ def push_to_try(self, message):
+ """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.
+ """
+
+ 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)
+
+
+class HgRepository(Repository):
+ """An implementation of `Repository` for Mercurial repositories."""
+
+ def __init__(self, path, hg="hg"):
+ import hglib.client
+
+ super(HgRepository, self).__init__(path, tool=hg)
+ self._env[b"HGPLAIN"] = b"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 = 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=b"UTF-8", configs=None, connect=False
+ )
+
+ # Work around py3 compat issues in python-hglib
+ self._client._env = ensure_subprocess_env(self._client._env)
+
+ @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 __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 = os.getcwd()
+ 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 = os.path.join(self.path, ".hg", "sparse")
+
+ try:
+ st = os.stat(sparse)
+ 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="default"):
+ template = self._files_template(diff_filter)
+ return self._run(
+ "outgoing",
+ "-r",
+ ".",
+ "--quiet",
+ "--template",
+ template,
+ upstream,
+ return_codes=(1,),
+ ).split()
+
+ def add_remove_files(self, *paths):
+ if not paths:
+ return
+ args = ["addremove"] + list(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):
+ if not paths:
+ return
+ 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 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):
+ if _paths_equal(self.path, path):
+ raise CannotDeleteFromRootOfRepositoryException()
+ self._run("revert", path)
+ for f in self._run("st", "-un", path).split():
+ if os.path.isfile(f):
+ os.remove(f)
+ else:
+ shutil.rmtree(f)
+
+ def push_to_try(self, message):
+ try:
+ subprocess.check_call(
+ (self._tool, "push-to-try", "-m", message),
+ cwd=self.path,
+ env=ensure_subprocess_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, 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
+
+ @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="default"):
+ assert all(f.lower() in self._valid_diff_filter for f in diff_filter)
+
+ not_condition = "--remotes" if upstream == "default" else upstream
+
+ 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):
+ if not paths:
+ return
+ self._run("add", *paths)
+
+ def forget_add_remove_files(self, *paths):
+ if not paths:
+ return
+ 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 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):
+ if _paths_equal(self.path, path):
+ raise CannotDeleteFromRootOfRepositoryException()
+ self._run("checkout", "--", path)
+ self._run("clean", "-df", path)
+
+ def push_to_try(self, message):
+ if not self.has_git_cinnabar:
+ raise MissingVCSExtension("cinnabar")
+
+ self._run(
+ "-c", "commit.gpgSign=false", "commit", "--allow-empty", "-m", message
+ )
+ try:
+ subprocess.check_call(
+ (
+ self._tool,
+ "push",
+ "hg::ssh://hg.mozilla.org/try",
+ "+HEAD:refs/heads/branches/default/tip",
+ ),
+ cwd=self.path,
+ )
+ finally:
+ self._run("reset", "HEAD~")
+
+ def set_config(self, name, value):
+ self._run("config", name, value)
+
+
+def get_repository_object(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 os.path.isdir(os.path.join(path, ".hg")):
+ return HgRepository(path, hg=hg)
+ elif os.path.exists(os.path.join(path, ".git")):
+ return GitRepository(path, git=git)
+ else:
+ raise InvalidRepoPath("Unknown VCS, or not a source checkout: %s" % 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(config.topsrcdir, hg=config.substs["HG"])
+ elif flavor == "git":
+ return GitRepository(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:
+ pass
+
+ def ancestors(path):
+ while path:
+ yield path
+ path, child = os.path.split(path)
+ if child == "":
+ break
+
+ for path in ancestors(os.getcwd()):
+ try:
+ return get_repository_object(path)
+ except InvalidRepoPath:
+ continue
+
+ raise MissingVCSInfo(
+ "Could not find Mercurial or Git checkout for %s" % os.getcwd()
+ )
diff --git a/python/mozversioncontrol/mozversioncontrol/repoupdate.py b/python/mozversioncontrol/mozversioncontrol/repoupdate.py
new file mode 100644
index 0000000000..af0c5d2ef8
--- /dev/null
+++ b/python/mozversioncontrol/mozversioncontrol/repoupdate.py
@@ -0,0 +1,41 @@
+# 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 __future__ import absolute_import, unicode_literals
+
+import os
+import subprocess
+
+# The logic here is far from robust. Improvements are welcome.
+
+
+def update_mercurial_repo(
+ hg, repo, path, revision="default", hostfingerprints=None, global_args=None
+):
+ """Ensure a HG repository exists at a path and is up to date."""
+ hostfingerprints = hostfingerprints or {}
+
+ 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 os.path.exists(path):
+ subprocess.check_call(args + ["pull", repo], cwd=path)
+ else:
+ subprocess.check_call(args + ["clone", repo, path])
+
+ subprocess.check_call([hg, "update", "-r", revision], cwd=path)
+
+
+def update_git_repo(git, repo, path, revision="origin/master"):
+ """Ensure a Git repository exists at a path and is up to date."""
+ if os.path.exists(path):
+ subprocess.check_call([git, "fetch", "--all"], cwd=path)
+ else:
+ subprocess.check_call([git, "clone", repo, path])
+
+ subprocess.check_call([git, "checkout", revision], cwd=path)
diff --git a/python/mozversioncontrol/setup.py b/python/mozversioncontrol/setup.py
new file mode 100644
index 0000000000..cf0d49d853
--- /dev/null
+++ b/python/mozversioncontrol/setup.py
@@ -0,0 +1,30 @@
+# 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 __future__ import absolute_import
+
+from setuptools import setup, find_packages
+
+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..5c9317dafc
--- /dev/null
+++ b/python/mozversioncontrol/test/conftest.py
@@ -0,0 +1,74 @@
+# 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 __future__ import absolute_import
+
+import os
+import subprocess
+
+import pytest
+
+
+SETUP = {
+ "hg": [
+ """
+ echo "foo" > foo
+ echo "bar" > bar
+ hg init
+ hg add *
+ hg commit -m "Initial commit"
+ """,
+ """
+ echo "[paths]\ndefault = ../remoterepo" > .hg/hgrc
+ """,
+ ],
+ "git": [
+ """
+ echo "foo" > foo
+ echo "bar" > bar
+ git init
+ git add *
+ git commit -am "Initial commit"
+ """,
+ """
+ git remote add upstream ../remoterepo
+ git fetch upstream
+ git branch -u upstream/master
+ """,
+ ],
+}
+
+
+def shell(cmd):
+ subprocess.check_call(cmd, shell=True)
+
+
+@pytest.yield_fixture(params=["git", "hg"])
+def repo(tmpdir, request):
+ vcs = request.param
+ steps = SETUP[vcs]
+
+ if hasattr(request.module, "STEPS"):
+ steps.extend(request.module.STEPS[vcs])
+
+ # tmpdir and repo are py.path objects
+ # http://py.readthedocs.io/en/latest/path.html
+ repo = tmpdir.mkdir("repo")
+ repo.vcs = vcs
+
+ # This creates a step iterator. Each time next() is called
+ # on it, the next set of instructions will be executed.
+ repo.step = (shell(cmd) for cmd in steps)
+
+ oldcwd = os.getcwd()
+ os.chdir(repo.strpath)
+
+ next(repo.step)
+
+ repo.copy(tmpdir.join("remoterepo"))
+
+ next(repo.step)
+
+ yield repo
+ os.chdir(oldcwd)
diff --git a/python/mozversioncontrol/test/python.ini b/python/mozversioncontrol/test/python.ini
new file mode 100644
index 0000000000..a9b1b7ca3a
--- /dev/null
+++ b/python/mozversioncontrol/test/python.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+subsuite=mozversioncontrol
+
+[test_context_manager.py]
+[test_push_to_try.py]
+[test_workdir_outgoing.py]
+[test_working_directory.py]
+[test_commit.py]
diff --git a/python/mozversioncontrol/test/test_commit.py b/python/mozversioncontrol/test/test_commit.py
new file mode 100644
index 0000000000..32b80db68a
--- /dev/null
+++ b/python/mozversioncontrol/test/test_commit.py
@@ -0,0 +1,74 @@
+# 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 __future__ import absolute_import
+
+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.strpath)
+ assert vcs.working_directory_clean()
+
+ # Modify both foo and bar
+ next(repo.step)
+ assert not vcs.working_directory_clean()
+
+ # Commit just bar
+ vcs.commit(
+ "Modify bar\n\nbut not baz",
+ "Testing McTesterson <test@example.org>",
+ "2017-07-14 02:40:00 UTC",
+ ["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,%at,%B"]
+ patch_cmd = ["log", "-1", "-p"]
+ else:
+ log_cmd = [
+ "log",
+ "-l",
+ "1",
+ "-T",
+ '{person(author)},{email(author)},{date(localdate(date),"%s")},{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,1500000000,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..10e1d31fd3
--- /dev/null
+++ b/python/mozversioncontrol/test/test_context_manager.py
@@ -0,0 +1,30 @@
+# 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 __future__ import absolute_import
+
+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.strpath)
+ 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..b3af244807
--- /dev/null
+++ b/python/mozversioncontrol/test/test_push_to_try.py
@@ -0,0 +1,86 @@
+# 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 __future__ import absolute_import, unicode_literals
+
+import subprocess
+
+import mozunit
+import pytest
+
+from mozversioncontrol import (
+ get_repository_object,
+ MissingVCSExtension,
+)
+
+
+def test_push_to_try(repo, monkeypatch):
+ commit_message = "commit message"
+ vcs = get_repository_object(repo.strpath)
+
+ 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 = [
+ (tool, "push-to-try", "-m", commit_message),
+ (tool, "revert", "-a"),
+ ]
+ else:
+ expected = [
+ (tool, "cinnabar", "--version"),
+ (
+ tool,
+ "-c",
+ "commit.gpgSign=false",
+ "commit",
+ "--allow-empty",
+ "-m",
+ commit_message,
+ ),
+ (
+ tool,
+ "push",
+ "hg::ssh://hg.mozilla.org/try",
+ "+HEAD:refs/heads/branches/default/tip",
+ ),
+ (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.strpath)
+
+ 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_workdir_outgoing.py b/python/mozversioncontrol/test/test_workdir_outgoing.py
new file mode 100644
index 0000000000..44bf4370a2
--- /dev/null
+++ b/python/mozversioncontrol/test/test_workdir_outgoing.py
@@ -0,0 +1,111 @@
+# 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 __future__ import absolute_import
+
+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.strpath)
+ assert vcs.path == repo.strpath
+
+ remotepath = "../remoterepo" if repo.vcs == "hg" else "upstream/master"
+
+ # Mutate files.
+ next(repo.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", remotepath), [])
+
+ # Create a commit.
+ next(repo.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", remotepath), ["bar", "baz", "foo"])
+
+ # Mutate again.
+ next(repo.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.
+ next(repo.step)
+
+ assert_files(vcs.get_outgoing_files("AM"), ["bar", "baz", "baby"])
+ assert_files(vcs.get_outgoing_files("AM", remotepath), ["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..11b7f360d1
--- /dev/null
+++ b/python/mozversioncontrol/test/test_working_directory.py
@@ -0,0 +1,48 @@
+# 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 __future__ import absolute_import
+
+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.strpath)
+ assert vcs.working_directory_clean()
+
+ next(repo.step)
+ assert not vcs.working_directory_clean()
+
+ next(repo.step)
+ assert vcs.working_directory_clean()
+ assert not vcs.working_directory_clean(untracked=True)
+
+
+if __name__ == "__main__":
+ mozunit.main()