1"""SCM Client definitions."""
2
3from __future__ import print_function, unicode_literals
4
5import logging
6import os
7import re
8import sys
9
10import pkg_resources
11import six
12
13from rbtools.clients.errors import SCMError
14from rbtools.utils.process import execute
15
16
17# The clients are lazy loaded via load_scmclients()
18SCMCLIENTS = None
19
20
21class PatchAuthor(object):
22    """The author of a patch or commit.
23
24    This wraps the full name and e-mail address of a commit or patch's
25    author primarily for use in :py:meth:`SCMClient.apply_patch`.
26
27    Attributes:
28        fullname (unicode):
29            The full name of the author.
30
31        email (unicode):
32            The e-mail address of the author.
33    """
34
35    def __init__(self, full_name, email):
36        """Initialize the author information.
37
38        Args:
39            full_name (unicode):
40                The full name of the author.
41
42            email (unicode):
43                The e-mail address of the author.
44        """
45        self.fullname = full_name
46        self.email = email
47
48
49class PatchResult(object):
50    """The result of a patch operation.
51
52    This stores state on whether the patch could be applied (fully or
53    partially), whether there are conflicts that can be resolved (as in
54    conflict markers, not reject files), which files conflicted, and the
55    patch output.
56    """
57
58    def __init__(self, applied, has_conflicts=False,
59                 conflicting_files=[], patch_output=None):
60        """Initialize the object.
61
62        Args:
63            applied (bool):
64                Whether the patch was applied.
65
66            has_conflicts (bool, optional):
67                Whether the applied patch included conflicts.
68
69            conflicting_files (list of unicode, optional):
70                A list of the filenames containing conflicts.
71
72            patch_output (unicode, optional):
73                The output of the patch command.
74        """
75        self.applied = applied
76        self.has_conflicts = has_conflicts
77        self.conflicting_files = conflicting_files
78        self.patch_output = patch_output
79
80
81class SCMClient(object):
82    """A base representation of an SCM tool.
83
84    These are used for fetching repository information and generating diffs.
85    """
86
87    name = None
88
89    #: Whether or not the SCM client can generate a commit history.
90    supports_commit_history = False
91    supports_diff_extra_args = False
92    supports_diff_exclude_patterns = False
93    supports_no_renames = False
94    supports_patch_revert = False
95
96    can_amend_commit = False
97    can_merge = False
98    can_push_upstream = False
99    can_delete_branch = False
100    can_branch = False
101    can_bookmark = False
102
103    #: Whether commits can be squashed during merge.
104    can_squash_merges = False
105
106    def __init__(self, config=None, options=None):
107        """Initialize the client.
108
109        Args:
110            config (dict, optional):
111                The loaded user config.
112
113            options (argparse.Namespace, optional):
114                The parsed command line arguments.
115        """
116        self.config = config or {}
117        self.options = options
118        self.capabilities = None
119
120    def get_repository_info(self):
121        """Return repository information for the current working tree.
122
123        This is expected to be overridden by subclasses.
124
125        Returns:
126            rbtools.clients.RepositoryInfo:
127            The repository info structure.
128        """
129        return None
130
131    def check_options(self):
132        """Verify the command line options.
133
134        This is expected to be overridden by subclasses, if they need to do
135        specific validation of the command line.
136
137        Raises:
138            rbtools.clients.errors.OptionsCheckError:
139                The supplied command line options were incorrect. In
140                particular, if a file has history scheduled with the commit,
141                the user needs to explicitly choose what behavior they want.
142        """
143        pass
144
145    def get_changenum(self, revisions):
146        """Return the change number for the given revisions.
147
148        This is only used when the client is supposed to send a change number
149        to the server (such as with Perforce).
150
151        Args:
152            revisions (dict):
153                A revisions dictionary as returned by ``parse_revision_spec``.
154
155        Returns:
156            unicode:
157            The change number to send to the Review Board server.
158        """
159        return None
160
161    def scan_for_server(self, repository_info):
162        """Find the server path.
163
164        This will search for the server name in the .reviewboardrc config
165        files. These are loaded with the current directory first, and searching
166        through each parent directory, and finally $HOME/.reviewboardrc last.
167
168        Args:
169            repository_info (rbtools.clients.RepositoryInfo):
170                The repository information structure.
171
172        Returns:
173            unicode:
174            The Review Board server URL, if available.
175        """
176        return self._get_server_from_config(self.config, repository_info)
177
178    def parse_revision_spec(self, revisions=[]):
179        """Parse the given revision spec.
180
181        The 'revisions' argument is a list of revisions as specified by the
182        user. Items in the list do not necessarily represent a single revision,
183        since the user can use SCM-native syntaxes such as "r1..r2" or "r1:r2".
184        SCMTool-specific overrides of this method are expected to deal with
185        such syntaxes.
186
187        Args:
188            revisions (list of unicode, optional):
189                A list of revisions as specified by the user. Items in the list
190                do not necessarily represent a single revision, since the user
191                can use SCM-native syntaxes such as ``r1..r2`` or ``r1:r2``.
192                SCMTool-specific overrides of this method are expected to deal
193                with such syntaxes.
194
195        Raises:
196            rbtools.clients.errors.InvalidRevisionSpecError:
197                The given revisions could not be parsed.
198
199            rbtools.clients.errors.TooManyRevisionsError:
200                The specified revisions list contained too many revisions.
201
202        Returns:
203            dict:
204            A dictionary with the following keys:
205
206            ``base`` (:py:class:`unicode`):
207                A revision to use as the base of the resulting diff.
208
209            ``tip`` (:py:class:`unicode`):
210                A revision to use as the tip of the resulting diff.
211
212            ``parent_base`` (:py:class:`unicode`, optional):
213                The revision to use as the base of a parent diff.
214
215            ``commit_id`` (:py:class:`unicode`, optional):
216                The ID of the single commit being posted, if not using a
217                range.
218
219            Additional keys may be included by subclasses for their own
220            internal use.
221
222            These will be used to generate the diffs to upload to Review Board
223            (or print). The diff for review will include the changes in (base,
224            tip], and the parent diff (if necessary) will include (parent,
225            base].
226
227            If a single revision is passed in, this will return the parent of
228            that revision for "base" and the passed-in revision for "tip".
229
230            If zero revisions are passed in, this will return revisions
231            relevant for the "current change". The exact definition of what
232            "current" means is specific to each SCMTool backend, and documented
233            in the implementation classes.
234        """
235        return {
236            'base': None,
237            'tip': None,
238        }
239
240    def diff(self, revisions, include_files=[], exclude_patterns=[],
241             no_renames=False, extra_args=[]):
242        """Perform a diff using the given revisions.
243
244        This is expected to be overridden by subclasses.
245
246        Args:
247            revisions (dict):
248                A dictionary of revisions, as returned by
249                :py:meth:`parse_revision_spec`.
250
251            include_files (list of unicode, optional):
252                A list of files to whitelist during the diff generation.
253
254            exclude_patterns (list of unicode, optional):
255                A list of shell-style glob patterns to blacklist during diff
256                generation.
257
258            extra_args (list, unused):
259                Additional arguments to be passed to the diff generation.
260
261        Returns:
262            dict:
263            A dictionary containing the following keys:
264
265            ``diff`` (:py:class:`bytes`):
266                The contents of the diff to upload.
267
268            ``parent_diff`` (:py:class:`bytes`, optional):
269                The contents of the parent diff, if available.
270
271            ``commit_id`` (:py:class:`unicode`, optional):
272                The commit ID to include when posting, if available.
273
274            ``base_commit_id` (:py:class:`unicode`, optional):
275                The ID of the commit that the change is based on, if available.
276                This is necessary for some hosting services that don't provide
277                individual file access.
278        """
279        return {
280            'diff': None,
281            'parent_diff': None,
282            'commit_id': None,
283            'base_commit_id': None,
284        }
285
286    def get_commit_history(self, revisions):
287        """Return the commit history between the given revisions.
288
289        Derived classes must override this method if they support posting with
290        history.
291
292        Args:
293            revisions (dict):
294                The parsed revision spec to use to generate the history.
295
296        Returns:
297            list of dict:
298            The history entries.
299        """
300        raise NotImplementedError
301
302    def _get_server_from_config(self, config, repository_info):
303        """Return the Review Board server URL in the config.
304
305        Args:
306            config (dict):
307                The loaded user config.
308
309            repository_info (rbtools.clients.RepositoryInfo):
310                The repository info structure.
311
312        Returns:
313            unicode:
314            The server URL, if available.
315        """
316        if 'REVIEWBOARD_URL' in config:
317            return config['REVIEWBOARD_URL']
318        elif 'TREES' in config:
319            trees = config['TREES']
320            if not isinstance(trees, dict):
321                raise ValueError('"TREES" in config file is not a dict!')
322
323            # If repository_info is a list, check if any one entry is in trees.
324            path = None
325
326            if isinstance(repository_info.path, list):
327                for path in repository_info.path:
328                    if path in trees:
329                        break
330                else:
331                    path = None
332            elif repository_info.path in trees:
333                path = repository_info.path
334
335            if path and 'REVIEWBOARD_URL' in trees[path]:
336                return trees[path]['REVIEWBOARD_URL']
337
338        return None
339
340    def _get_p_number(self, base_path, base_dir):
341        """Return the appropriate value for the -p argument to patch.
342
343        This function returns an integer. If the integer is -1, then the -p
344        option should not be provided to patch. Otherwise, the return value is
345        the argument to :command:`patch -p`.
346
347        Args:
348            base_path (unicode):
349                The relative path beetween the repository root and the
350                directory that the diff file was generated in.
351
352            base_dir (unicode):
353                The current relative path between the repository root and the
354                user's working directory.
355
356        Returns:
357            int:
358            The prefix number to pass into the :command:`patch` command.
359        """
360        if base_path and base_dir.startswith(base_path):
361            return base_path.count('/') + 1
362        else:
363            return -1
364
365    def _strip_p_num_slashes(self, files, p_num):
366        """Strip the smallest prefix containing p_num slashes from filenames.
367
368        To match the behavior of the :command:`patch -pX` option, adjacent
369        slashes are counted as a single slash.
370
371        Args:
372            files (list of unicode):
373                The filenames to process.
374
375            p_num (int):
376                The number of prefixes to strip.
377
378        Returns:
379            list of unicode:
380            The processed list of filenames.
381        """
382        if p_num > 0:
383            regex = re.compile(r'[^/]*/+')
384            return [regex.sub('', f, p_num) for f in files]
385        else:
386            return files
387
388    def has_pending_changes(self):
389        """Return whether there are changes waiting to be committed.
390
391        Derived classes should override this method if they wish to support
392        checking for pending changes.
393
394        Returns:
395            bool:
396            ``True`` if the working directory has been modified or if changes
397            have been staged in the index.
398        """
399        raise NotImplementedError
400
401    def apply_patch(self, patch_file, base_path, base_dir, p=None,
402                    revert=False):
403        """Apply the patch and return a PatchResult indicating its success.
404
405        Args:
406            patch_file (unicode):
407                The name of the patch file to apply.
408
409            base_path (unicode):
410                The base path that the diff was generated in.
411
412            base_dir (unicode):
413                The path of the current working directory relative to the root
414                of the repository.
415
416            p (unicode, optional):
417                The prefix level of the diff.
418
419            revert (bool, optional):
420                Whether the patch should be reverted rather than applied.
421
422        Returns:
423            rbtools.clients.PatchResult:
424            The result of the patch operation.
425        """
426        # Figure out the -p argument for patch. We override the calculated
427        # value if it is supplied via a commandline option.
428        p_num = p or self._get_p_number(base_path, base_dir)
429
430        cmd = ['patch']
431
432        if revert:
433            cmd.append('-R')
434
435        try:
436            p_num = int(p_num)
437        except ValueError:
438            p_num = 0
439            logging.warn('Invalid -p value: %s; assuming zero.', p_num)
440
441        if p_num is not None:
442            if p_num >= 0:
443                cmd.append('-p%d' % p_num)
444            else:
445                logging.warn('Unsupported -p value: %d; assuming zero.', p_num)
446
447        cmd.extend(['-i', six.text_type(patch_file)])
448
449        # Ignore return code 2 in case the patch file consists of only empty
450        # files, which 'patch' can't handle. Other 'patch' errors also give
451        # return code 2, so we must check the command output.
452        rc, patch_output = execute(cmd, extra_ignore_errors=(2,),
453                                   return_error_code=True)
454        only_garbage_in_patch = ('patch: **** Only garbage was found in the '
455                                 'patch input.\n')
456
457        if (patch_output and patch_output.startswith('patch: **** ') and
458            patch_output != only_garbage_in_patch):
459            raise SCMError('Failed to execute command: %s\n%s'
460                           % (cmd, patch_output))
461
462        # Check the patch for any added/deleted empty files to handle.
463        if self.supports_empty_files():
464            try:
465                with open(patch_file, 'rb') as f:
466                    patch = f.read()
467            except IOError as e:
468                logging.error('Unable to read file %s: %s', patch_file, e)
469                return
470
471            patched_empty_files = self.apply_patch_for_empty_files(
472                patch, p_num, revert=revert)
473
474            # If there are no empty files in a "garbage-only" patch, the patch
475            # is probably malformed.
476            if (patch_output == only_garbage_in_patch and
477                not patched_empty_files):
478                raise SCMError('Failed to execute command: %s\n%s'
479                               % (cmd, patch_output))
480
481        # TODO: Should this take into account apply_patch_for_empty_files ?
482        #       The return value of that function is False both when it fails
483        #       and when there are no empty files.
484        return PatchResult(applied=(rc == 0), patch_output=patch_output)
485
486    def create_commit(self, message, author, run_editor,
487                      files=[], all_files=False):
488        """Create a commit based on the provided message and author.
489
490        Derived classes should override this method if they wish to support
491        committing changes to their repositories.
492
493        Args:
494            message (unicode):
495                The commit message to use.
496
497            author (object):
498                The author of the commit. This is expected to have ``fullname``
499                and ``email`` attributes.
500
501            run_editor (bool):
502                Whether to run the user's editor on the commmit message before
503                committing.
504
505            files (list of unicode, optional):
506                The list of filenames to commit.
507
508            all_files (bool, optional):
509                Whether to commit all changed files, ignoring the ``files``
510                argument.
511
512        Raises:
513            NotImplementedError:
514                The client does not support creating commits.
515
516            rbtools.clients.errors.CreateCommitError:
517                The commit message could not be created. It may have been
518                aborted by the user.
519        """
520        raise NotImplementedError
521
522    def get_commit_message(self, revisions):
523        """Return the commit message from the commits in the given revisions.
524
525        This pulls out the first line from the commit messages of the given
526        revisions. That is then used as the summary.
527
528        Args:
529            revisions (dict):
530                A dictionary as returned by :py:meth:`parse_revision_spec`.
531
532        Returns:
533            dict:
534            A dictionary containing ``summary`` and ``description`` keys,
535            matching the first line of the commit message and the remainder,
536            respectively.
537        """
538        commit_message = self.get_raw_commit_message(revisions)
539        lines = commit_message.splitlines()
540
541        if not lines:
542            return None
543
544        result = {
545            'summary': lines[0],
546        }
547
548        # Try to pull the body of the commit out of the full commit
549        # description, so that we can skip the summary.
550        if len(lines) >= 3 and lines[0] and not lines[1]:
551            result['description'] = '\n'.join(lines[2:]).strip()
552        else:
553            result['description'] = commit_message
554
555        return result
556
557    def delete_branch(self, branch_name, merged_only=True):
558        """Delete the specified branch.
559
560        Args:
561            branch_name (unicode):
562                The name of the branch to delete.
563
564            merged_only (bool, optional):
565                Whether to limit branch deletion to only those branches which
566                have been merged into the current HEAD.
567        """
568        raise NotImplementedError
569
570    def merge(self, target, destination, message, author, squash=False,
571              run_editor=False, close_branch=True):
572        """Merge the target branch with destination branch.
573
574        Args:
575            target (unicode):
576                The name of the branch to merge.
577
578            destination (unicode):
579                The name of the branch to merge into.
580
581            message (unicode):
582                The commit message to use.
583
584            author (object):
585                The author of the commit. This is expected to have ``fullname``
586                and ``email`` attributes.
587
588            squash (bool, optional):
589                Whether to squash the commits or do a plain merge.
590
591            run_editor (bool, optional):
592                Whether to run the user's editor on the commmit message before
593                committing.
594
595            close_branch (bool, optional):
596                Whether to close/delete the merged branch.
597
598        Raises:
599            rbtools.clients.errors.MergeError:
600                An error occurred while merging the branch.
601        """
602        raise NotImplementedError
603
604    def push_upstream(self, remote_branch):
605        """Push the current branch to upstream.
606
607        Args:
608            remote_branch (unicode):
609                The name of the branch to push to.
610
611        Raises:
612            rbtools.client.errors.PushError:
613                The branch was unable to be pushed.
614        """
615        raise NotImplementedError
616
617    def get_raw_commit_message(self, revisions):
618        """Extract the commit messages on the commits in the given revisions.
619
620        Derived classes should override this method in order to allow callers
621        to fetch commit messages. This is needed for description guessing.
622
623        If a derived class is unable to fetch the description, ``None`` should
624        be returned.
625
626        Callers that need to differentiate the summary from the description
627        should instead use get_commit_message().
628
629        Args:
630            revisions (dict):
631                A dictionary containing ``base`` and ``tip`` keys.
632
633        Returns:
634            unicode:
635            The commit messages of all commits between (base, tip].
636        """
637        raise NotImplementedError
638
639    def get_current_branch(self):
640        """Return the repository branch name of the current directory.
641
642        Derived classes should override this method if they are able to
643        determine the current branch of the working directory.
644
645        Returns:
646            unicode:
647            A string with the name of the current branch. If the branch is
648            unable to be determined, returns ``None``.
649        """
650        raise NotImplementedError
651
652    def supports_empty_files(self):
653        """Return whether the server supports added/deleted empty files.
654
655        Returns:
656            bool:
657            ``True`` if the Review Board server supports added or deleted empty
658            files.
659        """
660        return False
661
662    def apply_patch_for_empty_files(self, patch, p_num, revert=False):
663        """Return whether any empty files in the patch are applied.
664
665        Args:
666            patch (bytes):
667                The contents of the patch.
668
669            p_num (unicode):
670                The prefix level of the diff.
671
672            revert (bool, optional):
673                Whether the patch should be reverted rather than applied.
674
675        Returns:
676            ``True`` if there are empty files in the patch. ``False`` if there
677            were no empty files, or if an error occurred while applying the
678            patch.
679        """
680        raise NotImplementedError
681
682    def amend_commit_description(self, message, revisions=None):
683        """Update a commit message to the given string.
684
685        Args:
686            message (unicode):
687                The commit message to use when amending the commit.
688
689            revisions (dict, optional):
690                A dictionary of revisions, as returned by
691                :py:meth:`parse_revision_spec`. This provides compatibility
692                with SCMs that allow modifications of multiple changesets at
693                any given time, and will amend the change referenced by the
694                ``tip`` key.
695
696        Raises:
697            rbtools.clients.errors.AmendError:
698                The amend operation failed.
699        """
700        raise NotImplementedError
701
702
703class RepositoryInfo(object):
704    """A representation of a source code repository."""
705
706    def __init__(self, path=None, base_path=None, local_path=None, name=None,
707                 supports_changesets=False, supports_parent_diffs=False):
708        """Initialize the object.
709
710        Args:
711            path (unicode or list of unicode, optional):
712                The path of the repository, or a list of possible paths
713                (with the primary one appearing first).
714
715            base_path (unicode, optional):
716                The relative path between the current working directory and the
717                repository root.
718
719            local_path (unicode, optional):
720                The local filesystem path for the repository. This can
721                sometimes be the same as ``path``, but may not be (since that
722                can contain a remote repository path).
723
724            name (unicode, optional):
725                The name of the repository, as configured on Review Board.
726                This might be available through some repository metadata.
727
728            supports_changesets (bool, optional):
729                Whether the repository type supports changesets that store
730                their data server-side.
731
732            supports_parent_diffs (bool, optional):
733                Whether the repository type supports posting changes with
734                parent diffs.
735        """
736        self.path = path
737        self.base_path = base_path
738        self.local_path = local_path
739        self.name = name
740        self.supports_changesets = supports_changesets
741        self.supports_parent_diffs = supports_parent_diffs
742        logging.debug('Repository info: %s', self)
743
744    def __str__(self):
745        """Return a string representation of the repository info.
746
747        Returns:
748            unicode:
749            A loggable representation.
750        """
751        return 'Path: %s, Base path: %s, Supports changesets: %s' % \
752            (self.path, self.base_path, self.supports_changesets)
753
754    def set_base_path(self, base_path):
755        """Set the base path of the repository info.
756
757        Args:
758            base_path (unicode):
759                The relative path between the current working directory and the
760                repository root.
761        """
762        if not base_path.startswith('/'):
763            base_path = '/' + base_path
764
765        logging.debug('changing repository info base_path from %s to %s',
766                      self.base_path, base_path)
767        self.base_path = base_path
768
769    def find_server_repository_info(self, server):
770        """Try to find the repository from the list of repositories on the server.
771
772        For Subversion, this could be a repository with a different URL. For
773        all other clients, this is a noop.
774
775        Args:
776            server (rbtools.api.resource.RootResource):
777                The root resource for the Review Board server.
778
779        Returns:
780            RepositoryInfo:
781            The server-side information for this repository.
782        """
783        return self
784
785
786def load_scmclients(config, options):
787    """Load the available SCM clients.
788
789    Args:
790        config (dict):
791            The loaded user config.
792
793        options (argparse.Namespace):
794            The parsed command line arguments.
795    """
796    global SCMCLIENTS
797
798    SCMCLIENTS = {}
799
800    for ep in pkg_resources.iter_entry_points(group='rbtools_scm_clients'):
801        try:
802            client = ep.load()(config=config, options=options)
803            client.entrypoint_name = ep.name
804            SCMCLIENTS[ep.name] = client
805        except Exception:
806            logging.exception('Could not load SCM Client "%s"', ep.name)
807
808
809def scan_usable_client(config, options, client_name=None,
810                       require_repository_info=True):
811    """Scan for a usable SCMClient.
812
813    Args:
814        config (dict):
815            The loaded user config.
816
817        options (argparse.Namespace):
818            The parsed command line arguments.
819
820        client_name (unicode, optional):
821            A specific client name, which can come from the configuration. This
822            can be used to disambiguate if there are nested repositories, or to
823            speed up detection.
824
825        require_repository_info (bool, optional):
826            Whether information on a repository is required. This is the
827            default. If disabled, this will return ``None`` for the repository
828            information if a matching repository could not be found.
829
830    Returns:
831        tuple:
832        A 2-tuple, containing the repository info structure and the tool
833        instance.
834    """
835    from rbtools.clients.perforce import PerforceClient
836
837    repository_info = None
838    tool = None
839
840    # TODO: We should only load all of the scm clients if the client_name
841    # isn't provided.
842    if SCMCLIENTS is None:
843        load_scmclients(config, options)
844
845    if client_name:
846        if client_name not in SCMCLIENTS:
847            logging.error('The provided repository type "%s" is invalid.',
848                          client_name)
849            sys.exit(1)
850        else:
851            scmclients = {
852                client_name: SCMCLIENTS[client_name]
853            }
854    else:
855        scmclients = SCMCLIENTS
856
857    candidate_repos = []
858
859    for name, tool in six.iteritems(scmclients):
860        logging.debug('Checking for a %s repository...', tool.name)
861        repository_info = tool.get_repository_info()
862
863        if repository_info:
864            candidate_repos.append((repository_info, tool))
865
866    if candidate_repos:
867        if len(candidate_repos) == 1:
868            repository_info, tool = candidate_repos[0]
869        else:
870            logging.debug('Finding deepest repository of multiple matching '
871                          'repository types.')
872
873            deepest_repo_len = 0
874            deepest_repo_info = None
875            deepest_repo_tool = None
876
877            for repo, tool in candidate_repos:
878                if (repo.local_path and
879                    len(os.path.normpath(repo.local_path)) > deepest_repo_len):
880                    deepest_repo_len = len(repo.local_path)
881                    deepest_repo_info = repo
882                    deepest_repo_tool = tool
883
884            if deepest_repo_info:
885                repository_info = deepest_repo_info
886                tool = deepest_repo_tool
887
888                logging.warn('Multiple matching repositories were found. '
889                             'Using %s repository at %s.',
890                             tool.name, repository_info.local_path)
891                logging.warn('Define REPOSITORY_TYPE in .reviewboardrc if '
892                             'you wish to use a different repository.')
893            else:
894                # If finding the deepest repository fails (for example, when
895                # posting against a remote SVN repository there will be no
896                # local path), just default to the first repository found
897                repository_info, tool = candidate_repos[0]
898
899    if repository_info is not None:
900        # Verify that options specific to an SCM Client have not been mis-used.
901        if (getattr(options, 'change_only', False) and
902            not repository_info.supports_changesets):
903            logging.error('The --change-only option is not valid for the '
904                          'current SCM client.\n')
905            sys.exit(1)
906
907        if (getattr(options, 'parent_branch', None) and
908            not repository_info.supports_parent_diffs):
909            logging.error('The --parent option is not valid for the '
910                          'current SCM client.')
911            sys.exit(1)
912
913        if (not isinstance(tool, PerforceClient) and
914            (getattr(options, 'p4_client', None) or
915             getattr(options, 'p4_port', None))):
916            logging.error('The --p4-client and --p4-port options are not '
917                          'valid for the current SCM client.\n')
918            sys.exit(1)
919    elif require_repository_info:
920        if client_name:
921            logging.error('The provided repository type was not detected '
922                          'in the current directory.')
923        elif getattr(options, 'repository_url', None):
924            logging.error('No supported repository could be accessed at '
925                          'the supplied url.')
926        else:
927            logging.error('The current directory does not contain a checkout '
928                          'from a supported source code repository.')
929
930        sys.exit(1)
931
932    return repository_info, tool
933
934
935def print_clients(config, options):
936    """Print the supported detected SCM clients.
937
938    Each SCM client, including those provided by third party packages,
939    will be printed. Additionally, SCM clients which are detected in
940    the current directory will be highlighted.
941
942    Args:
943        config (dict):
944            The loaded user config.
945
946        options (argparse.Namespace):
947            The parsed command line options.
948    """
949    print('The following repository types are supported by this installation')
950    print('of RBTools. Each "<type>" may be used as a value for the')
951    print('"--repository-type=<type>" command line argument. Repository types')
952    print('which are detected in the current directory are marked with a "*"')
953    print('[*] "<type>": <Name>')
954
955    if SCMCLIENTS is None:
956        load_scmclients(config, options)
957
958    for name, tool in six.iteritems(SCMCLIENTS):
959        repository_info = tool.get_repository_info()
960
961        if repository_info:
962            print(' * "%s": %s' % (name, tool.name))
963        else:
964            print('   "%s": %s' % (name, tool.name))
965