blob: 939a4630e6273b3ae11bc24589832076e63cae99 [file] [log] [blame]
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -07001# Copyright (C) 2008 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
Mike Frysingerb5d075d2021-03-01 00:56:38 -050015import multiprocessing
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070016import os
17import optparse
Colin Cross5acde752012-03-28 20:15:45 -070018import re
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070019import sys
20
David Rileye0684ad2017-04-05 00:02:59 -070021from event_log import EventLog
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070022from error import NoSuchProjectError
Colin Cross5acde752012-03-28 20:15:45 -070023from error import InvalidProjectGroupsError
Mike Frysingerb5d075d2021-03-01 00:56:38 -050024import progress
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070025
David Pursehouseb148ac92012-11-16 09:33:39 +090026
Mike Frysingerdf8b1cb2021-07-26 15:59:20 -040027# Are we generating man-pages?
Gavin Makea2e3302023-03-11 06:46:20 +000028GENERATE_MANPAGES = os.environ.get("_REPO_GENERATE_MANPAGES_") == " indeed! "
Mike Frysingerdf8b1cb2021-07-26 15:59:20 -040029
30
Mike Frysinger7c871162021-02-16 01:45:39 -050031# Number of projects to submit to a single worker process at a time.
32# This number represents a tradeoff between the overhead of IPC and finer
33# grained opportunity for parallelism. This particular value was chosen by
34# iterating through powers of two until the overall performance no longer
35# improved. The performance of this batch size is not a function of the
36# number of cores on the system.
37WORKER_BATCH_SIZE = 32
38
39
Mike Frysinger6a2400a2021-02-16 01:43:31 -050040# How many jobs to run in parallel by default? This assumes the jobs are
41# largely I/O bound and do not hit the network.
42DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8)
43
44
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070045class Command(object):
Gavin Makea2e3302023-03-11 06:46:20 +000046 """Base class for any command line action in repo."""
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070047
Gavin Makea2e3302023-03-11 06:46:20 +000048 # Singleton for all commands to track overall repo command execution and
49 # provide event summary to callers. Only used by sync subcommand currently.
50 #
51 # NB: This is being replaced by git trace2 events. See git_trace2_event_log.
52 event_log = EventLog()
Mike Frysingerd88b3692021-06-14 16:09:29 -040053
Gavin Makea2e3302023-03-11 06:46:20 +000054 # Whether this command is a "common" one, i.e. whether the user would
55 # commonly use it or it's a more uncommon command. This is used by the help
56 # command to show short-vs-full summaries.
57 COMMON = False
Mike Frysinger4f210542021-06-14 16:05:19 -040058
Gavin Makea2e3302023-03-11 06:46:20 +000059 # Whether this command supports running in parallel. If greater than 0,
60 # it is the number of parallel jobs to default to.
61 PARALLEL_JOBS = None
Mike Frysinger6a2400a2021-02-16 01:43:31 -050062
Gavin Makea2e3302023-03-11 06:46:20 +000063 # Whether this command supports Multi-manifest. If False, then main.py will
64 # iterate over the manifests and invoke the command once per (sub)manifest.
65 # This is only checked after calling ValidateOptions, so that partially
66 # migrated subcommands can set it to False.
67 MULTI_MANIFEST_SUPPORT = True
LaMont Jonescc879a92021-11-18 22:40:18 +000068
Gavin Makea2e3302023-03-11 06:46:20 +000069 def __init__(
70 self,
71 repodir=None,
72 client=None,
73 manifest=None,
74 gitc_manifest=None,
75 git_event_log=None,
76 outer_client=None,
77 outer_manifest=None,
78 ):
79 self.repodir = repodir
80 self.client = client
81 self.outer_client = outer_client or client
82 self.manifest = manifest
83 self.gitc_manifest = gitc_manifest
84 self.git_event_log = git_event_log
85 self.outer_manifest = outer_manifest
Mike Frysingerd58d0dd2021-06-14 16:17:27 -040086
Gavin Makea2e3302023-03-11 06:46:20 +000087 # Cache for the OptionParser property.
88 self._optparse = None
Mike Frysingerd58d0dd2021-06-14 16:17:27 -040089
Gavin Makea2e3302023-03-11 06:46:20 +000090 def WantPager(self, _opt):
91 return False
Shawn O. Pearcedb45da12009-04-18 13:49:13 -070092
Gavin Makea2e3302023-03-11 06:46:20 +000093 def ReadEnvironmentOptions(self, opts):
94 """Set options from environment variables."""
David Pursehouseb148ac92012-11-16 09:33:39 +090095
Gavin Makea2e3302023-03-11 06:46:20 +000096 env_options = self._RegisteredEnvironmentOptions()
David Pursehouseb148ac92012-11-16 09:33:39 +090097
Gavin Makea2e3302023-03-11 06:46:20 +000098 for env_key, opt_key in env_options.items():
99 # Get the user-set option value if any
100 opt_value = getattr(opts, opt_key)
David Pursehouseb148ac92012-11-16 09:33:39 +0900101
Gavin Makea2e3302023-03-11 06:46:20 +0000102 # If the value is set, it means the user has passed it as a command
103 # line option, and we should use that. Otherwise we can try to set
104 # it with the value from the corresponding environment variable.
105 if opt_value is not None:
106 continue
David Pursehouseb148ac92012-11-16 09:33:39 +0900107
Gavin Makea2e3302023-03-11 06:46:20 +0000108 env_value = os.environ.get(env_key)
109 if env_value is not None:
110 setattr(opts, opt_key, env_value)
David Pursehouseb148ac92012-11-16 09:33:39 +0900111
Gavin Makea2e3302023-03-11 06:46:20 +0000112 return opts
David Pursehouseb148ac92012-11-16 09:33:39 +0900113
Gavin Makea2e3302023-03-11 06:46:20 +0000114 @property
115 def OptionParser(self):
116 if self._optparse is None:
117 try:
118 me = "repo %s" % self.NAME
119 usage = self.helpUsage.strip().replace("%prog", me)
120 except AttributeError:
121 usage = "repo %s" % self.NAME
122 epilog = (
123 "Run `repo help %s` to view the detailed manual." % self.NAME
124 )
125 self._optparse = optparse.OptionParser(usage=usage, epilog=epilog)
126 self._CommonOptions(self._optparse)
127 self._Options(self._optparse)
128 return self._optparse
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700129
Gavin Makea2e3302023-03-11 06:46:20 +0000130 def _CommonOptions(self, p, opt_v=True):
131 """Initialize the option parser with common options.
Mike Frysinger9180a072021-04-13 14:57:40 -0400132
Gavin Makea2e3302023-03-11 06:46:20 +0000133 These will show up for *all* subcommands, so use sparingly.
134 NB: Keep in sync with repo:InitParser().
135 """
136 g = p.add_option_group("Logging options")
137 opts = ["-v"] if opt_v else []
138 g.add_option(
139 *opts,
140 "--verbose",
141 dest="output_mode",
142 action="store_true",
143 help="show all output",
144 )
145 g.add_option(
146 "-q",
147 "--quiet",
148 dest="output_mode",
149 action="store_false",
150 help="only show errors",
151 )
Mike Frysinger9180a072021-04-13 14:57:40 -0400152
Gavin Makea2e3302023-03-11 06:46:20 +0000153 if self.PARALLEL_JOBS is not None:
154 default = "based on number of CPU cores"
155 if not GENERATE_MANPAGES:
156 # Only include active cpu count if we aren't generating man
157 # pages.
158 default = f"%default; {default}"
159 p.add_option(
160 "-j",
161 "--jobs",
162 type=int,
163 default=self.PARALLEL_JOBS,
164 help=f"number of jobs to run in parallel (default: {default})",
165 )
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700166
Gavin Makea2e3302023-03-11 06:46:20 +0000167 m = p.add_option_group("Multi-manifest options")
168 m.add_option(
169 "--outer-manifest",
170 action="store_true",
171 default=None,
172 help="operate starting at the outermost manifest",
173 )
174 m.add_option(
175 "--no-outer-manifest",
176 dest="outer_manifest",
177 action="store_false",
178 help="do not operate on outer manifests",
179 )
180 m.add_option(
181 "--this-manifest-only",
182 action="store_true",
183 default=None,
184 help="only operate on this (sub)manifest",
185 )
186 m.add_option(
187 "--no-this-manifest-only",
188 "--all-manifests",
189 dest="this_manifest_only",
190 action="store_false",
191 help="operate on this manifest and its submanifests",
192 )
LaMont Jonescc879a92021-11-18 22:40:18 +0000193
Gavin Makea2e3302023-03-11 06:46:20 +0000194 def _Options(self, p):
195 """Initialize the option parser with subcommand-specific options."""
Mike Frysinger9180a072021-04-13 14:57:40 -0400196
Gavin Makea2e3302023-03-11 06:46:20 +0000197 def _RegisteredEnvironmentOptions(self):
198 """Get options that can be set from environment variables.
David Pursehouseb148ac92012-11-16 09:33:39 +0900199
Gavin Makea2e3302023-03-11 06:46:20 +0000200 Return a dictionary mapping environment variable name
201 to option key name that it can override.
David Pursehouseb148ac92012-11-16 09:33:39 +0900202
Gavin Makea2e3302023-03-11 06:46:20 +0000203 Example: {'REPO_MY_OPTION': 'my_option'}
David Pursehouseb148ac92012-11-16 09:33:39 +0900204
Gavin Makea2e3302023-03-11 06:46:20 +0000205 Will allow the option with key value 'my_option' to be set
206 from the value in the environment variable named 'REPO_MY_OPTION'.
David Pursehouseb148ac92012-11-16 09:33:39 +0900207
Gavin Makea2e3302023-03-11 06:46:20 +0000208 Note: This does not work properly for options that are explicitly
209 set to None by the user, or options that are defined with a
210 default value other than None.
David Pursehouseb148ac92012-11-16 09:33:39 +0900211
Gavin Makea2e3302023-03-11 06:46:20 +0000212 """
213 return {}
David Pursehouseb148ac92012-11-16 09:33:39 +0900214
Gavin Makea2e3302023-03-11 06:46:20 +0000215 def Usage(self):
216 """Display usage and terminate."""
217 self.OptionParser.print_usage()
218 sys.exit(1)
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700219
Gavin Makea2e3302023-03-11 06:46:20 +0000220 def CommonValidateOptions(self, opt, args):
221 """Validate common options."""
222 opt.quiet = opt.output_mode is False
223 opt.verbose = opt.output_mode is True
224 if opt.outer_manifest is None:
225 # By default, treat multi-manifest instances as a single manifest
226 # from the user's perspective.
227 opt.outer_manifest = True
Mike Frysinger9180a072021-04-13 14:57:40 -0400228
Gavin Makea2e3302023-03-11 06:46:20 +0000229 def ValidateOptions(self, opt, args):
230 """Validate the user options & arguments before executing.
Mike Frysingerae6cb082019-08-27 01:10:59 -0400231
Gavin Makea2e3302023-03-11 06:46:20 +0000232 This is meant to help break the code up into logical steps. Some tips:
233 * Use self.OptionParser.error to display CLI related errors.
234 * Adjust opt member defaults as makes sense.
235 * Adjust the args list, but do so inplace so the caller sees updates.
236 * Try to avoid updating self state. Leave that to Execute.
237 """
Mike Frysingerae6cb082019-08-27 01:10:59 -0400238
Gavin Makea2e3302023-03-11 06:46:20 +0000239 def Execute(self, opt, args):
240 """Perform the action, after option parsing is complete."""
241 raise NotImplementedError
Conley Owens971de8e2012-04-16 10:36:08 -0700242
Gavin Makea2e3302023-03-11 06:46:20 +0000243 @staticmethod
244 def ExecuteInParallel(
245 jobs, func, inputs, callback, output=None, ordered=False
246 ):
247 """Helper for managing parallel execution boiler plate.
Mike Frysingerb5d075d2021-03-01 00:56:38 -0500248
Gavin Makea2e3302023-03-11 06:46:20 +0000249 For subcommands that can easily split their work up.
Mike Frysingerb5d075d2021-03-01 00:56:38 -0500250
Gavin Makea2e3302023-03-11 06:46:20 +0000251 Args:
252 jobs: How many parallel processes to use.
253 func: The function to apply to each of the |inputs|. Usually a
254 functools.partial for wrapping additional arguments. It will be
255 run in a separate process, so it must be pickalable, so nested
256 functions won't work. Methods on the subcommand Command class
257 should work.
258 inputs: The list of items to process. Must be a list.
259 callback: The function to pass the results to for processing. It
260 will be executed in the main thread and process the results of
261 |func| as they become available. Thus it may be a local nested
262 function. Its return value is passed back directly. It takes
263 three arguments:
264 - The processing pool (or None with one job).
265 - The |output| argument.
266 - An iterator for the results.
267 output: An output manager. May be progress.Progess or
268 color.Coloring.
269 ordered: Whether the jobs should be processed in order.
Mike Frysingerb5d075d2021-03-01 00:56:38 -0500270
Gavin Makea2e3302023-03-11 06:46:20 +0000271 Returns:
272 The |callback| function's results are returned.
273 """
Che-Liang Chioub2bd91c2012-01-11 11:28:42 +0800274 try:
Gavin Makea2e3302023-03-11 06:46:20 +0000275 # NB: Multiprocessing is heavy, so don't spin it up for one job.
276 if len(inputs) == 1 or jobs == 1:
277 return callback(None, output, (func(x) for x in inputs))
278 else:
279 with multiprocessing.Pool(jobs) as pool:
280 submit = pool.imap if ordered else pool.imap_unordered
281 return callback(
282 pool,
283 output,
284 submit(func, inputs, chunksize=WORKER_BATCH_SIZE),
285 )
286 finally:
287 if isinstance(output, progress.Progress):
288 output.end()
Che-Liang Chioub2bd91c2012-01-11 11:28:42 +0800289
Gavin Makea2e3302023-03-11 06:46:20 +0000290 def _ResetPathToProjectMap(self, projects):
291 self._by_path = dict((p.worktree, p) for p in projects)
LaMont Jonesff6b1da2022-06-01 21:03:34 +0000292
Gavin Makea2e3302023-03-11 06:46:20 +0000293 def _UpdatePathToProjectMap(self, project):
294 self._by_path[project.worktree] = project
LaMont Jonesff6b1da2022-06-01 21:03:34 +0000295
Gavin Makea2e3302023-03-11 06:46:20 +0000296 def _GetProjectByPath(self, manifest, path):
297 project = None
298 if os.path.exists(path):
299 oldpath = None
300 while path and path != oldpath and path != manifest.topdir:
301 try:
302 project = self._by_path[path]
303 break
304 except KeyError:
305 oldpath = path
306 path = os.path.dirname(path)
307 if not project and path == manifest.topdir:
308 try:
309 project = self._by_path[path]
310 except KeyError:
311 pass
312 else:
313 try:
314 project = self._by_path[path]
315 except KeyError:
316 pass
317 return project
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700318
Gavin Makea2e3302023-03-11 06:46:20 +0000319 def GetProjects(
320 self,
321 args,
322 manifest=None,
323 groups="",
324 missing_ok=False,
325 submodules_ok=False,
326 all_manifests=False,
327 ):
328 """A list of projects that match the arguments.
Colin Cross5acde752012-03-28 20:15:45 -0700329
Gavin Makea2e3302023-03-11 06:46:20 +0000330 Args:
331 args: a list of (case-insensitive) strings, projects to search for.
332 manifest: an XmlManifest, the manifest to use, or None for default.
333 groups: a string, the manifest groups in use.
334 missing_ok: a boolean, whether to allow missing projects.
335 submodules_ok: a boolean, whether to allow submodules.
336 all_manifests: a boolean, if True then all manifests and
337 submanifests are used. If False, then only the local
338 (sub)manifest is used.
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700339
Gavin Makea2e3302023-03-11 06:46:20 +0000340 Returns:
341 A list of matching Project instances.
342 """
343 if all_manifests:
344 if not manifest:
345 manifest = self.manifest.outer_client
346 all_projects_list = manifest.all_projects
347 else:
348 if not manifest:
349 manifest = self.manifest
350 all_projects_list = manifest.projects
351 result = []
352
353 if not groups:
354 groups = manifest.GetGroupsStr()
355 groups = [x for x in re.split(r"[,\s]+", groups) if x]
356
357 if not args:
358 derived_projects = {}
359 for project in all_projects_list:
360 if submodules_ok or project.sync_s:
361 derived_projects.update(
362 (p.name, p) for p in project.GetDerivedSubprojects()
363 )
364 all_projects_list.extend(derived_projects.values())
365 for project in all_projects_list:
366 if (missing_ok or project.Exists) and project.MatchesGroups(
367 groups
368 ):
369 result.append(project)
370 else:
371 self._ResetPathToProjectMap(all_projects_list)
372
373 for arg in args:
374 # We have to filter by manifest groups in case the requested
375 # project is checked out multiple times or differently based on
376 # them.
377 projects = [
378 project
Sergiy Belozorov78e82ec2023-01-05 18:57:31 +0100379 for project in manifest.GetProjectsWithName(
Gavin Makea2e3302023-03-11 06:46:20 +0000380 arg, all_manifests=all_manifests
381 )
382 if project.MatchesGroups(groups)
383 ]
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700384
Gavin Makea2e3302023-03-11 06:46:20 +0000385 if not projects:
386 path = os.path.abspath(arg).replace("\\", "/")
387 tree = manifest
388 if all_manifests:
389 # Look for the deepest matching submanifest.
390 for tree in reversed(list(manifest.all_manifests)):
391 if path.startswith(tree.topdir):
392 break
393 project = self._GetProjectByPath(tree, path)
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700394
Gavin Makea2e3302023-03-11 06:46:20 +0000395 # If it's not a derived project, update path->project
396 # mapping and search again, as arg might actually point to
397 # a derived subproject.
398 if (
399 project
400 and not project.Derived
401 and (submodules_ok or project.sync_s)
402 ):
403 search_again = False
404 for subproject in project.GetDerivedSubprojects():
405 self._UpdatePathToProjectMap(subproject)
406 search_again = True
407 if search_again:
408 project = (
409 self._GetProjectByPath(manifest, path)
410 or project
411 )
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700412
Gavin Makea2e3302023-03-11 06:46:20 +0000413 if project:
414 projects = [project]
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700415
Gavin Makea2e3302023-03-11 06:46:20 +0000416 if not projects:
417 raise NoSuchProjectError(arg)
David James8d201162013-10-11 17:03:19 -0700418
Gavin Makea2e3302023-03-11 06:46:20 +0000419 for project in projects:
420 if not missing_ok and not project.Exists:
421 raise NoSuchProjectError(
422 "%s (%s)"
423 % (arg, project.RelPath(local=not all_manifests))
424 )
425 if not project.MatchesGroups(groups):
426 raise InvalidProjectGroupsError(arg)
David James8d201162013-10-11 17:03:19 -0700427
Gavin Makea2e3302023-03-11 06:46:20 +0000428 result.extend(projects)
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700429
Gavin Makea2e3302023-03-11 06:46:20 +0000430 def _getpath(x):
431 return x.relpath
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700432
Gavin Makea2e3302023-03-11 06:46:20 +0000433 result.sort(key=_getpath)
434 return result
LaMont Jonescc879a92021-11-18 22:40:18 +0000435
Gavin Makea2e3302023-03-11 06:46:20 +0000436 def FindProjects(self, args, inverse=False, all_manifests=False):
437 """Find projects from command line arguments.
Zhiguang Lia8864fb2013-03-15 10:32:10 +0800438
Gavin Makea2e3302023-03-11 06:46:20 +0000439 Args:
440 args: a list of (case-insensitive) strings, projects to search for.
441 inverse: a boolean, if True, then projects not matching any |args|
442 are returned.
443 all_manifests: a boolean, if True then all manifests and
444 submanifests are used. If False, then only the local
445 (sub)manifest is used.
446 """
447 result = []
448 patterns = [re.compile(r"%s" % a, re.IGNORECASE) for a in args]
449 for project in self.GetProjects("", all_manifests=all_manifests):
450 paths = [project.name, project.RelPath(local=not all_manifests)]
451 for pattern in patterns:
452 match = any(pattern.search(x) for x in paths)
453 if not inverse and match:
454 result.append(project)
455 break
456 if inverse and match:
457 break
458 else:
459 if inverse:
460 result.append(project)
461 result.sort(
462 key=lambda project: (project.manifest.path_prefix, project.relpath)
463 )
464 return result
LaMont Jonescc879a92021-11-18 22:40:18 +0000465
Gavin Makea2e3302023-03-11 06:46:20 +0000466 def ManifestList(self, opt):
467 """Yields all of the manifests to traverse.
468
469 Args:
470 opt: The command options.
471 """
472 top = self.outer_manifest
473 if not opt.outer_manifest or opt.this_manifest_only:
474 top = self.manifest
475 yield top
476 if not opt.this_manifest_only:
477 for child in top.all_children:
478 yield child
LaMont Jonescc879a92021-11-18 22:40:18 +0000479
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700480
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700481class InteractiveCommand(Command):
Gavin Makea2e3302023-03-11 06:46:20 +0000482 """Command which requires user interaction on the tty and must not run
483 within a pager, even if the user asks to.
484 """
David Pursehouse819827a2020-02-12 15:20:19 +0900485
Gavin Makea2e3302023-03-11 06:46:20 +0000486 def WantPager(self, _opt):
487 return False
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700488
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700489
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700490class PagedCommand(Command):
Gavin Makea2e3302023-03-11 06:46:20 +0000491 """Command which defaults to output in a pager, as its display tends to be
492 larger than one screen full.
493 """
David Pursehouse819827a2020-02-12 15:20:19 +0900494
Gavin Makea2e3302023-03-11 06:46:20 +0000495 def WantPager(self, _opt):
496 return True
Shawn O. Pearcec95583b2009-03-03 17:47:06 -0800497
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700498
Shawn O. Pearcec95583b2009-03-03 17:47:06 -0800499class MirrorSafeCommand(object):
Gavin Makea2e3302023-03-11 06:46:20 +0000500 """Command permits itself to run within a mirror, and does not require a
501 working directory.
502 """
Dan Willemsen9ff2ece2015-08-31 15:45:06 -0700503
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700504
Dan Willemsen79360642015-08-31 15:45:06 -0700505class GitcAvailableCommand(object):
Gavin Makea2e3302023-03-11 06:46:20 +0000506 """Command that requires GITC to be available, but does not require the
507 local client to be a GITC client.
508 """
Dan Willemsen79360642015-08-31 15:45:06 -0700509
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700510
Dan Willemsen79360642015-08-31 15:45:06 -0700511class GitcClientCommand(object):
Gavin Makea2e3302023-03-11 06:46:20 +0000512 """Command that requires the local client to be a GITC client."""