blob: d2129381482d46f2d092705d04dc93231c8cd8d8 [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 -070019
David Rileye0684ad2017-04-05 00:02:59 -070020from event_log import EventLog
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070021from error import NoSuchProjectError
Colin Cross5acde752012-03-28 20:15:45 -070022from error import InvalidProjectGroupsError
Jason Changf9aacd42023-08-03 14:38:00 -070023from error import RepoExitError
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
Jason Changf9aacd42023-08-03 14:38:00 -070045class UsageError(RepoExitError):
46 """Exception thrown with invalid command usage."""
47
48
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070049class Command(object):
Gavin Makea2e3302023-03-11 06:46:20 +000050 """Base class for any command line action in repo."""
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -070051
Gavin Makea2e3302023-03-11 06:46:20 +000052 # Singleton for all commands to track overall repo command execution and
53 # provide event summary to callers. Only used by sync subcommand currently.
54 #
55 # NB: This is being replaced by git trace2 events. See git_trace2_event_log.
56 event_log = EventLog()
Mike Frysingerd88b3692021-06-14 16:09:29 -040057
Gavin Makea2e3302023-03-11 06:46:20 +000058 # Whether this command is a "common" one, i.e. whether the user would
59 # commonly use it or it's a more uncommon command. This is used by the help
60 # command to show short-vs-full summaries.
61 COMMON = False
Mike Frysinger4f210542021-06-14 16:05:19 -040062
Gavin Makea2e3302023-03-11 06:46:20 +000063 # Whether this command supports running in parallel. If greater than 0,
64 # it is the number of parallel jobs to default to.
65 PARALLEL_JOBS = None
Mike Frysinger6a2400a2021-02-16 01:43:31 -050066
Gavin Makea2e3302023-03-11 06:46:20 +000067 # Whether this command supports Multi-manifest. If False, then main.py will
68 # iterate over the manifests and invoke the command once per (sub)manifest.
69 # This is only checked after calling ValidateOptions, so that partially
70 # migrated subcommands can set it to False.
71 MULTI_MANIFEST_SUPPORT = True
LaMont Jonescc879a92021-11-18 22:40:18 +000072
Gavin Makea2e3302023-03-11 06:46:20 +000073 def __init__(
74 self,
75 repodir=None,
76 client=None,
77 manifest=None,
78 gitc_manifest=None,
79 git_event_log=None,
80 outer_client=None,
81 outer_manifest=None,
82 ):
83 self.repodir = repodir
84 self.client = client
85 self.outer_client = outer_client or client
86 self.manifest = manifest
87 self.gitc_manifest = gitc_manifest
88 self.git_event_log = git_event_log
89 self.outer_manifest = outer_manifest
Mike Frysingerd58d0dd2021-06-14 16:17:27 -040090
Gavin Makea2e3302023-03-11 06:46:20 +000091 # Cache for the OptionParser property.
92 self._optparse = None
Mike Frysingerd58d0dd2021-06-14 16:17:27 -040093
Gavin Makea2e3302023-03-11 06:46:20 +000094 def WantPager(self, _opt):
95 return False
Shawn O. Pearcedb45da12009-04-18 13:49:13 -070096
Gavin Makea2e3302023-03-11 06:46:20 +000097 def ReadEnvironmentOptions(self, opts):
98 """Set options from environment variables."""
David Pursehouseb148ac92012-11-16 09:33:39 +090099
Gavin Makea2e3302023-03-11 06:46:20 +0000100 env_options = self._RegisteredEnvironmentOptions()
David Pursehouseb148ac92012-11-16 09:33:39 +0900101
Gavin Makea2e3302023-03-11 06:46:20 +0000102 for env_key, opt_key in env_options.items():
103 # Get the user-set option value if any
104 opt_value = getattr(opts, opt_key)
David Pursehouseb148ac92012-11-16 09:33:39 +0900105
Gavin Makea2e3302023-03-11 06:46:20 +0000106 # If the value is set, it means the user has passed it as a command
107 # line option, and we should use that. Otherwise we can try to set
108 # it with the value from the corresponding environment variable.
109 if opt_value is not None:
110 continue
David Pursehouseb148ac92012-11-16 09:33:39 +0900111
Gavin Makea2e3302023-03-11 06:46:20 +0000112 env_value = os.environ.get(env_key)
113 if env_value is not None:
114 setattr(opts, opt_key, env_value)
David Pursehouseb148ac92012-11-16 09:33:39 +0900115
Gavin Makea2e3302023-03-11 06:46:20 +0000116 return opts
David Pursehouseb148ac92012-11-16 09:33:39 +0900117
Gavin Makea2e3302023-03-11 06:46:20 +0000118 @property
119 def OptionParser(self):
120 if self._optparse is None:
121 try:
122 me = "repo %s" % self.NAME
123 usage = self.helpUsage.strip().replace("%prog", me)
124 except AttributeError:
125 usage = "repo %s" % self.NAME
126 epilog = (
127 "Run `repo help %s` to view the detailed manual." % self.NAME
128 )
129 self._optparse = optparse.OptionParser(usage=usage, epilog=epilog)
130 self._CommonOptions(self._optparse)
131 self._Options(self._optparse)
132 return self._optparse
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700133
Gavin Makea2e3302023-03-11 06:46:20 +0000134 def _CommonOptions(self, p, opt_v=True):
135 """Initialize the option parser with common options.
Mike Frysinger9180a072021-04-13 14:57:40 -0400136
Gavin Makea2e3302023-03-11 06:46:20 +0000137 These will show up for *all* subcommands, so use sparingly.
138 NB: Keep in sync with repo:InitParser().
139 """
140 g = p.add_option_group("Logging options")
141 opts = ["-v"] if opt_v else []
142 g.add_option(
143 *opts,
144 "--verbose",
145 dest="output_mode",
146 action="store_true",
147 help="show all output",
148 )
149 g.add_option(
150 "-q",
151 "--quiet",
152 dest="output_mode",
153 action="store_false",
154 help="only show errors",
155 )
Mike Frysinger9180a072021-04-13 14:57:40 -0400156
Gavin Makea2e3302023-03-11 06:46:20 +0000157 if self.PARALLEL_JOBS is not None:
158 default = "based on number of CPU cores"
159 if not GENERATE_MANPAGES:
160 # Only include active cpu count if we aren't generating man
161 # pages.
162 default = f"%default; {default}"
163 p.add_option(
164 "-j",
165 "--jobs",
166 type=int,
167 default=self.PARALLEL_JOBS,
168 help=f"number of jobs to run in parallel (default: {default})",
169 )
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700170
Gavin Makea2e3302023-03-11 06:46:20 +0000171 m = p.add_option_group("Multi-manifest options")
172 m.add_option(
173 "--outer-manifest",
174 action="store_true",
175 default=None,
176 help="operate starting at the outermost manifest",
177 )
178 m.add_option(
179 "--no-outer-manifest",
180 dest="outer_manifest",
181 action="store_false",
182 help="do not operate on outer manifests",
183 )
184 m.add_option(
185 "--this-manifest-only",
186 action="store_true",
187 default=None,
188 help="only operate on this (sub)manifest",
189 )
190 m.add_option(
191 "--no-this-manifest-only",
192 "--all-manifests",
193 dest="this_manifest_only",
194 action="store_false",
195 help="operate on this manifest and its submanifests",
196 )
LaMont Jonescc879a92021-11-18 22:40:18 +0000197
Gavin Makea2e3302023-03-11 06:46:20 +0000198 def _Options(self, p):
199 """Initialize the option parser with subcommand-specific options."""
Mike Frysinger9180a072021-04-13 14:57:40 -0400200
Gavin Makea2e3302023-03-11 06:46:20 +0000201 def _RegisteredEnvironmentOptions(self):
202 """Get options that can be set from environment variables.
David Pursehouseb148ac92012-11-16 09:33:39 +0900203
Gavin Makea2e3302023-03-11 06:46:20 +0000204 Return a dictionary mapping environment variable name
205 to option key name that it can override.
David Pursehouseb148ac92012-11-16 09:33:39 +0900206
Gavin Makea2e3302023-03-11 06:46:20 +0000207 Example: {'REPO_MY_OPTION': 'my_option'}
David Pursehouseb148ac92012-11-16 09:33:39 +0900208
Gavin Makea2e3302023-03-11 06:46:20 +0000209 Will allow the option with key value 'my_option' to be set
210 from the value in the environment variable named 'REPO_MY_OPTION'.
David Pursehouseb148ac92012-11-16 09:33:39 +0900211
Gavin Makea2e3302023-03-11 06:46:20 +0000212 Note: This does not work properly for options that are explicitly
213 set to None by the user, or options that are defined with a
214 default value other than None.
David Pursehouseb148ac92012-11-16 09:33:39 +0900215
Gavin Makea2e3302023-03-11 06:46:20 +0000216 """
217 return {}
David Pursehouseb148ac92012-11-16 09:33:39 +0900218
Gavin Makea2e3302023-03-11 06:46:20 +0000219 def Usage(self):
220 """Display usage and terminate."""
221 self.OptionParser.print_usage()
Jason Changf9aacd42023-08-03 14:38:00 -0700222 raise UsageError()
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700223
Gavin Makea2e3302023-03-11 06:46:20 +0000224 def CommonValidateOptions(self, opt, args):
225 """Validate common options."""
226 opt.quiet = opt.output_mode is False
227 opt.verbose = opt.output_mode is True
228 if opt.outer_manifest is None:
229 # By default, treat multi-manifest instances as a single manifest
230 # from the user's perspective.
231 opt.outer_manifest = True
Mike Frysinger9180a072021-04-13 14:57:40 -0400232
Gavin Makea2e3302023-03-11 06:46:20 +0000233 def ValidateOptions(self, opt, args):
234 """Validate the user options & arguments before executing.
Mike Frysingerae6cb082019-08-27 01:10:59 -0400235
Gavin Makea2e3302023-03-11 06:46:20 +0000236 This is meant to help break the code up into logical steps. Some tips:
237 * Use self.OptionParser.error to display CLI related errors.
238 * Adjust opt member defaults as makes sense.
239 * Adjust the args list, but do so inplace so the caller sees updates.
240 * Try to avoid updating self state. Leave that to Execute.
241 """
Mike Frysingerae6cb082019-08-27 01:10:59 -0400242
Gavin Makea2e3302023-03-11 06:46:20 +0000243 def Execute(self, opt, args):
244 """Perform the action, after option parsing is complete."""
245 raise NotImplementedError
Conley Owens971de8e2012-04-16 10:36:08 -0700246
Gavin Makea2e3302023-03-11 06:46:20 +0000247 @staticmethod
248 def ExecuteInParallel(
249 jobs, func, inputs, callback, output=None, ordered=False
250 ):
251 """Helper for managing parallel execution boiler plate.
Mike Frysingerb5d075d2021-03-01 00:56:38 -0500252
Gavin Makea2e3302023-03-11 06:46:20 +0000253 For subcommands that can easily split their work up.
Mike Frysingerb5d075d2021-03-01 00:56:38 -0500254
Gavin Makea2e3302023-03-11 06:46:20 +0000255 Args:
256 jobs: How many parallel processes to use.
257 func: The function to apply to each of the |inputs|. Usually a
258 functools.partial for wrapping additional arguments. It will be
259 run in a separate process, so it must be pickalable, so nested
260 functions won't work. Methods on the subcommand Command class
261 should work.
262 inputs: The list of items to process. Must be a list.
263 callback: The function to pass the results to for processing. It
264 will be executed in the main thread and process the results of
265 |func| as they become available. Thus it may be a local nested
266 function. Its return value is passed back directly. It takes
267 three arguments:
268 - The processing pool (or None with one job).
269 - The |output| argument.
270 - An iterator for the results.
271 output: An output manager. May be progress.Progess or
272 color.Coloring.
273 ordered: Whether the jobs should be processed in order.
Mike Frysingerb5d075d2021-03-01 00:56:38 -0500274
Gavin Makea2e3302023-03-11 06:46:20 +0000275 Returns:
276 The |callback| function's results are returned.
277 """
Che-Liang Chioub2bd91c2012-01-11 11:28:42 +0800278 try:
Gavin Makea2e3302023-03-11 06:46:20 +0000279 # NB: Multiprocessing is heavy, so don't spin it up for one job.
280 if len(inputs) == 1 or jobs == 1:
281 return callback(None, output, (func(x) for x in inputs))
282 else:
283 with multiprocessing.Pool(jobs) as pool:
284 submit = pool.imap if ordered else pool.imap_unordered
285 return callback(
286 pool,
287 output,
288 submit(func, inputs, chunksize=WORKER_BATCH_SIZE),
289 )
290 finally:
291 if isinstance(output, progress.Progress):
292 output.end()
Che-Liang Chioub2bd91c2012-01-11 11:28:42 +0800293
Gavin Makea2e3302023-03-11 06:46:20 +0000294 def _ResetPathToProjectMap(self, projects):
295 self._by_path = dict((p.worktree, p) for p in projects)
LaMont Jonesff6b1da2022-06-01 21:03:34 +0000296
Gavin Makea2e3302023-03-11 06:46:20 +0000297 def _UpdatePathToProjectMap(self, project):
298 self._by_path[project.worktree] = project
LaMont Jonesff6b1da2022-06-01 21:03:34 +0000299
Gavin Makea2e3302023-03-11 06:46:20 +0000300 def _GetProjectByPath(self, manifest, path):
301 project = None
302 if os.path.exists(path):
303 oldpath = None
304 while path and path != oldpath and path != manifest.topdir:
305 try:
306 project = self._by_path[path]
307 break
308 except KeyError:
309 oldpath = path
310 path = os.path.dirname(path)
311 if not project and path == manifest.topdir:
312 try:
313 project = self._by_path[path]
314 except KeyError:
315 pass
316 else:
317 try:
318 project = self._by_path[path]
319 except KeyError:
320 pass
321 return project
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700322
Gavin Makea2e3302023-03-11 06:46:20 +0000323 def GetProjects(
324 self,
325 args,
326 manifest=None,
327 groups="",
328 missing_ok=False,
329 submodules_ok=False,
330 all_manifests=False,
331 ):
332 """A list of projects that match the arguments.
Colin Cross5acde752012-03-28 20:15:45 -0700333
Gavin Makea2e3302023-03-11 06:46:20 +0000334 Args:
335 args: a list of (case-insensitive) strings, projects to search for.
336 manifest: an XmlManifest, the manifest to use, or None for default.
337 groups: a string, the manifest groups in use.
338 missing_ok: a boolean, whether to allow missing projects.
339 submodules_ok: a boolean, whether to allow submodules.
340 all_manifests: a boolean, if True then all manifests and
341 submanifests are used. If False, then only the local
342 (sub)manifest is used.
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700343
Gavin Makea2e3302023-03-11 06:46:20 +0000344 Returns:
345 A list of matching Project instances.
346 """
347 if all_manifests:
348 if not manifest:
349 manifest = self.manifest.outer_client
350 all_projects_list = manifest.all_projects
351 else:
352 if not manifest:
353 manifest = self.manifest
354 all_projects_list = manifest.projects
355 result = []
356
357 if not groups:
358 groups = manifest.GetGroupsStr()
359 groups = [x for x in re.split(r"[,\s]+", groups) if x]
360
361 if not args:
362 derived_projects = {}
363 for project in all_projects_list:
364 if submodules_ok or project.sync_s:
365 derived_projects.update(
366 (p.name, p) for p in project.GetDerivedSubprojects()
367 )
368 all_projects_list.extend(derived_projects.values())
369 for project in all_projects_list:
370 if (missing_ok or project.Exists) and project.MatchesGroups(
371 groups
372 ):
373 result.append(project)
374 else:
375 self._ResetPathToProjectMap(all_projects_list)
376
377 for arg in args:
378 # We have to filter by manifest groups in case the requested
379 # project is checked out multiple times or differently based on
380 # them.
381 projects = [
382 project
Sergiy Belozorov78e82ec2023-01-05 18:57:31 +0100383 for project in manifest.GetProjectsWithName(
Gavin Makea2e3302023-03-11 06:46:20 +0000384 arg, all_manifests=all_manifests
385 )
386 if project.MatchesGroups(groups)
387 ]
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700388
Gavin Makea2e3302023-03-11 06:46:20 +0000389 if not projects:
390 path = os.path.abspath(arg).replace("\\", "/")
391 tree = manifest
392 if all_manifests:
393 # Look for the deepest matching submanifest.
394 for tree in reversed(list(manifest.all_manifests)):
395 if path.startswith(tree.topdir):
396 break
397 project = self._GetProjectByPath(tree, path)
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700398
Gavin Makea2e3302023-03-11 06:46:20 +0000399 # If it's not a derived project, update path->project
400 # mapping and search again, as arg might actually point to
401 # a derived subproject.
402 if (
403 project
404 and not project.Derived
405 and (submodules_ok or project.sync_s)
406 ):
407 search_again = False
408 for subproject in project.GetDerivedSubprojects():
409 self._UpdatePathToProjectMap(subproject)
410 search_again = True
411 if search_again:
412 project = (
413 self._GetProjectByPath(manifest, path)
414 or project
415 )
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700416
Gavin Makea2e3302023-03-11 06:46:20 +0000417 if project:
418 projects = [project]
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700419
Gavin Makea2e3302023-03-11 06:46:20 +0000420 if not projects:
421 raise NoSuchProjectError(arg)
David James8d201162013-10-11 17:03:19 -0700422
Gavin Makea2e3302023-03-11 06:46:20 +0000423 for project in projects:
424 if not missing_ok and not project.Exists:
425 raise NoSuchProjectError(
426 "%s (%s)"
427 % (arg, project.RelPath(local=not all_manifests))
428 )
429 if not project.MatchesGroups(groups):
430 raise InvalidProjectGroupsError(arg)
David James8d201162013-10-11 17:03:19 -0700431
Gavin Makea2e3302023-03-11 06:46:20 +0000432 result.extend(projects)
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700433
Gavin Makea2e3302023-03-11 06:46:20 +0000434 def _getpath(x):
435 return x.relpath
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700436
Gavin Makea2e3302023-03-11 06:46:20 +0000437 result.sort(key=_getpath)
438 return result
LaMont Jonescc879a92021-11-18 22:40:18 +0000439
Gavin Makea2e3302023-03-11 06:46:20 +0000440 def FindProjects(self, args, inverse=False, all_manifests=False):
441 """Find projects from command line arguments.
Zhiguang Lia8864fb2013-03-15 10:32:10 +0800442
Gavin Makea2e3302023-03-11 06:46:20 +0000443 Args:
444 args: a list of (case-insensitive) strings, projects to search for.
445 inverse: a boolean, if True, then projects not matching any |args|
446 are returned.
447 all_manifests: a boolean, if True then all manifests and
448 submanifests are used. If False, then only the local
449 (sub)manifest is used.
450 """
451 result = []
452 patterns = [re.compile(r"%s" % a, re.IGNORECASE) for a in args]
453 for project in self.GetProjects("", all_manifests=all_manifests):
454 paths = [project.name, project.RelPath(local=not all_manifests)]
455 for pattern in patterns:
456 match = any(pattern.search(x) for x in paths)
457 if not inverse and match:
458 result.append(project)
459 break
460 if inverse and match:
461 break
462 else:
463 if inverse:
464 result.append(project)
465 result.sort(
466 key=lambda project: (project.manifest.path_prefix, project.relpath)
467 )
468 return result
LaMont Jonescc879a92021-11-18 22:40:18 +0000469
Gavin Makea2e3302023-03-11 06:46:20 +0000470 def ManifestList(self, opt):
471 """Yields all of the manifests to traverse.
472
473 Args:
474 opt: The command options.
475 """
476 top = self.outer_manifest
477 if not opt.outer_manifest or opt.this_manifest_only:
478 top = self.manifest
479 yield top
480 if not opt.this_manifest_only:
481 for child in top.all_children:
482 yield child
LaMont Jonescc879a92021-11-18 22:40:18 +0000483
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700484
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700485class InteractiveCommand(Command):
Gavin Makea2e3302023-03-11 06:46:20 +0000486 """Command which requires user interaction on the tty and must not run
487 within a pager, even if the user asks to.
488 """
David Pursehouse819827a2020-02-12 15:20:19 +0900489
Gavin Makea2e3302023-03-11 06:46:20 +0000490 def WantPager(self, _opt):
491 return False
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700492
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700493
The Android Open Source Projectcf31fe92008-10-21 07:00:00 -0700494class PagedCommand(Command):
Gavin Makea2e3302023-03-11 06:46:20 +0000495 """Command which defaults to output in a pager, as its display tends to be
496 larger than one screen full.
497 """
David Pursehouse819827a2020-02-12 15:20:19 +0900498
Gavin Makea2e3302023-03-11 06:46:20 +0000499 def WantPager(self, _opt):
500 return True
Shawn O. Pearcec95583b2009-03-03 17:47:06 -0800501
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700502
Shawn O. Pearcec95583b2009-03-03 17:47:06 -0800503class MirrorSafeCommand(object):
Gavin Makea2e3302023-03-11 06:46:20 +0000504 """Command permits itself to run within a mirror, and does not require a
505 working directory.
506 """
Dan Willemsen9ff2ece2015-08-31 15:45:06 -0700507
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700508
Dan Willemsen79360642015-08-31 15:45:06 -0700509class GitcAvailableCommand(object):
Gavin Makea2e3302023-03-11 06:46:20 +0000510 """Command that requires GITC to be available, but does not require the
511 local client to be a GITC client.
512 """
Dan Willemsen79360642015-08-31 15:45:06 -0700513
Mark E. Hamilton8ccfa742016-02-10 10:44:30 -0700514
Dan Willemsen79360642015-08-31 15:45:06 -0700515class GitcClientCommand(object):
Gavin Makea2e3302023-03-11 06:46:20 +0000516 """Command that requires the local client to be a GITC client."""