Bug 1401309 - [mozversioncontrol] Add ability to get outgoing files, r?gps draft
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Wed, 20 Sep 2017 10:15:09 -0400
changeset 671957 770fe8a9981a39d2891913b297710e6b676a4c6f
parent 671956 519bd5b9386eaa4cb87101e55a87fb5aa75741bb
child 671958 ea4c86bcb01c486e947c71164e69c707895abdb3
push id82100
push userahalberstadt@mozilla.com
push dateThu, 28 Sep 2017 14:54:56 +0000
reviewersgps
bugs1401309
milestone58.0a1
Bug 1401309 - [mozversioncontrol] Add ability to get outgoing files, r?gps This adds 'get_outgoing_files'. First it automatically attempts to find the upstream remote the current change is based on, then returns all files changed in the local branch. If an upstream remote can't be detected, it raises MissingUpstreamRepo MozReview-Commit-ID: 9zSB9EdwVU8
python/mozversioncontrol/mozversioncontrol/__init__.py
--- a/python/mozversioncontrol/mozversioncontrol/__init__.py
+++ b/python/mozversioncontrol/mozversioncontrol/__init__.py
@@ -13,16 +13,32 @@ import which
 
 from distutils.version import LooseVersion
 
 
 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 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."""
+
+
 def get_tool_path(tool):
     """Obtain the path of `tool`."""
     if os.path.isabs(tool) and os.path.exists(tool):
         return tool
 
     # We use subprocess in places, which expects a Win32 executable or
     # batch script. On some versions of MozillaBuild, we have "hg.exe",
     # "hg.bat," and "hg" (a Python script). "which" will happily return the
@@ -94,16 +110,20 @@ class Repository(object):
 
         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_upstream(self):
+        """Reference to the upstream remote."""
+
+    @abc.abstractmethod
     def get_changed_files(self, diff_filter, mode='unstaged'):
         """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
@@ -112,16 +132,26 @@ class Repository(object):
 
         By default, all three will be included.
 
         ``mode`` can be one of 'unstaged', 'staged' or 'all'. Only has an
         affect on git. Defaults to 'unstaged'.
         """
 
     @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, path):
         '''Add and remove files under `path` in this repository's working copy.
         '''
 
     @abc.abstractmethod
     def forget_add_remove_files(self, path):
         '''Undo the effects of a previous add_remove_files call for `path`.
         '''
@@ -200,32 +230,49 @@ class HgRepository(Repository):
             st = os.stat(sparse)
             return st.st_size > 0
         except OSError as e:
             if e.errno != errno.ENOENT:
                 raise
 
             return False
 
+    def get_upstream(self):
+        return 'default'
+
     def _format_diff_filter(self, diff_filter):
         df = diff_filter.lower()
         assert all(f in self._valid_diff_filter for f in df)
 
         # Mercurial uses 'r' to denote removed files whereas git uses 'd'.
         if 'd' in df:
             df.replace('d', 'r')
 
         return df.lower()
 
     def get_changed_files(self, diff_filter='ADM', mode='unstaged'):
         df = self._format_diff_filter(diff_filter)
 
         # Use --no-status to print just the filename.
         return self._run('status', '--no-status', '-{}'.format(df)).splitlines()
 
+    def get_outgoing_files(self, diff_filter='ADM', upstream='default'):
+        df = self._format_diff_filter(diff_filter)
+
+        template = ''
+        if 'a' in df:
+            template += "{file_adds % '\\n{file}'}"
+        if 'd' in df:
+            template += "{file_dels % '\\n{file}'}"
+        if 'm' in df:
+            template += "{file_mods % '\\n{file}'}"
+
+        return self._run('outgoing', '-r', '.', '--quiet',
+                         '--template', template, upstream).split()
+
     def add_remove_files(self, path):
         args = ['addremove', path]
         if self.tool_version >= b'3.9':
             args = ['--config', 'extensions.automv='] + args
         self._run(*args)
 
     def forget_add_remove_files(self, path):
         self._run('forget', path)
@@ -257,27 +304,46 @@ class GitRepository(Repository):
     @property
     def name(self):
         return 'git'
 
     def sparse_checkout_present(self):
         # Not yet implemented.
         return False
 
+    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'):
         assert all(f.lower() in self._valid_diff_filter for f in diff_filter)
 
         cmd = ['diff', '--diff-filter={}'.format(diff_filter.upper()), '--name-only']
         if mode == 'staged':
             cmd.append('--cached')
         elif mode == 'all':
             cmd.append('HEAD')
 
         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)
+
+        if upstream == 'default':
+            upstream = self.get_upstream()
+
+        compare = '{}..HEAD'.format(upstream)
+        return self._run('log', '--name-only', '--diff-filter={}'.format(diff_filter.upper()),
+                         '--oneline', '--pretty=format:', compare).splitlines()
+
     def add_remove_files(self, path):
         self._run('add', path)
 
     def forget_add_remove_files(self, path):
         self._run('reset', path)
 
     def get_files_in_working_directory(self):
         return self._run('ls-files', '-z').split(b'\0')
@@ -287,41 +353,29 @@ class GitRepository(Repository):
         if untracked:
             args.append('--untracked-files')
         if ignored:
             args.append('--ignored')
 
         return not len(self._run(*args).strip())
 
 
-class InvalidRepoPath(Exception):
-    """Represents a failure to find a VCS repo at a specified path."""
-
-
 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)
 
 
-class MissingVCSInfo(Exception):
-    """Represents a general failure to resolve a VCS interface."""
-
-
-class MissingConfigureInfo(MissingVCSInfo):
-    """Represents error finding VCS info from configure data."""
-
-
 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