1import os
2import sys
3import fcntl
4
5import click
6from .. import _yaml
7from .._exceptions import BstError, LoadError, AppError
8from .._versions import BST_FORMAT_VERSION
9from .complete import main_bashcomplete, complete_path, CompleteUnhandled
10
11
12##################################################################
13#            Override of click's main entry point                #
14##################################################################
15
16# search_command()
17#
18# Helper function to get a command and context object
19# for a given command.
20#
21# Args:
22#    commands (list): A list of command words following `bst` invocation
23#    context (click.Context): An existing toplevel context, or None
24#
25# Returns:
26#    context (click.Context): The context of the associated command, or None
27#
28def search_command(args, *, context=None):
29    if context is None:
30        context = cli.make_context('bst', args, resilient_parsing=True)
31
32    # Loop into the deepest command
33    command = cli
34    command_ctx = context
35    for cmd in args:
36        command = command_ctx.command.get_command(command_ctx, cmd)
37        if command is None:
38            return None
39        command_ctx = command.make_context(command.name, [command.name],
40                                           parent=command_ctx,
41                                           resilient_parsing=True)
42
43    return command_ctx
44
45
46# Completion for completing command names as help arguments
47def complete_commands(cmd, args, incomplete):
48    command_ctx = search_command(args[1:])
49    if command_ctx and command_ctx.command and isinstance(command_ctx.command, click.MultiCommand):
50        return [subcommand + " " for subcommand in command_ctx.command.list_commands(command_ctx)]
51
52    return []
53
54
55# Special completion for completing the bst elements in a project dir
56def complete_target(args, incomplete):
57    """
58    :param args: full list of args typed before the incomplete arg
59    :param incomplete: the incomplete text to autocomplete
60    :return: all the possible user-specified completions for the param
61    """
62
63    project_conf = 'project.conf'
64
65    def ensure_project_dir(directory):
66        directory = os.path.abspath(directory)
67        while not os.path.isfile(os.path.join(directory, project_conf)):
68            parent_dir = os.path.dirname(directory)
69            if directory == parent_dir:
70                break
71            directory = parent_dir
72
73        return directory
74
75    # First resolve the directory, in case there is an
76    # active --directory/-C option
77    #
78    base_directory = '.'
79    idx = -1
80    try:
81        idx = args.index('-C')
82    except ValueError:
83        try:
84            idx = args.index('--directory')
85        except ValueError:
86            pass
87
88    if idx >= 0 and len(args) > idx + 1:
89        base_directory = args[idx + 1]
90    else:
91        # Check if this directory or any of its parent directories
92        # contain a project config file
93        base_directory = ensure_project_dir(base_directory)
94
95    # Now parse the project.conf just to find the element path,
96    # this is unfortunately a bit heavy.
97    project_file = os.path.join(base_directory, project_conf)
98    try:
99        project = _yaml.load(project_file)
100    except LoadError:
101        # If there is no project directory in context, just dont
102        # even bother trying to complete anything.
103        return []
104
105    # The project is not required to have an element-path
106    element_directory = project.get('element-path')
107
108    # If a project was loaded, use it's element-path to
109    # adjust our completion's base directory
110    if element_directory:
111        base_directory = os.path.join(base_directory, element_directory)
112
113    return complete_path("File", incomplete, base_directory=base_directory)
114
115
116def override_completions(cmd, cmd_param, args, incomplete):
117    """
118    :param cmd_param: command definition
119    :param args: full list of args typed before the incomplete arg
120    :param incomplete: the incomplete text to autocomplete
121    :return: all the possible user-specified completions for the param
122    """
123
124    if cmd.name == 'help':
125        return complete_commands(cmd, args, incomplete)
126
127    # We can't easily extend click's data structures without
128    # modifying click itself, so just do some weak special casing
129    # right here and select which parameters we want to handle specially.
130    if isinstance(cmd_param.type, click.Path) and \
131       (cmd_param.name == 'elements' or
132        cmd_param.name == 'element' or
133        cmd_param.name == 'except_' or
134        cmd_param.opts == ['--track'] or
135        cmd_param.opts == ['--track-except']):
136        return complete_target(args, incomplete)
137
138    raise CompleteUnhandled()
139
140
141def override_main(self, args=None, prog_name=None, complete_var=None,
142                  standalone_mode=True, **extra):
143
144    # Hook for the Bash completion.  This only activates if the Bash
145    # completion is actually enabled, otherwise this is quite a fast
146    # noop.
147    if main_bashcomplete(self, prog_name, override_completions):
148
149        # If we're running tests we cant just go calling exit()
150        # from the main process.
151        #
152        # The below is a quicker exit path for the sake
153        # of making completions respond faster.
154        if 'BST_TEST_SUITE' not in os.environ:
155            sys.stdout.flush()
156            sys.stderr.flush()
157            os._exit(0)
158
159        # Regular client return for test cases
160        return
161
162    # Check output file descriptor at earliest opportunity, to
163    # provide a reasonable error message instead of a stack trace
164    # in the case that it is blocking
165    for stream in (sys.stdout, sys.stderr):
166        fileno = stream.fileno()
167        flags = fcntl.fcntl(fileno, fcntl.F_GETFL)
168        if flags & os.O_NONBLOCK:
169            click.echo("{} is currently set to O_NONBLOCK, try opening a new shell"
170                       .format(stream.name), err=True)
171            sys.exit(-1)
172
173    original_main(self, args=args, prog_name=prog_name, complete_var=None,
174                  standalone_mode=standalone_mode, **extra)
175
176
177original_main = click.BaseCommand.main
178click.BaseCommand.main = override_main
179
180
181##################################################################
182#                          Main Options                          #
183##################################################################
184def print_version(ctx, param, value):
185    if not value or ctx.resilient_parsing:
186        return
187
188    from .. import __version__
189    click.echo(__version__)
190    ctx.exit()
191
192
193@click.group(context_settings=dict(help_option_names=['-h', '--help']))
194@click.option('--version', is_flag=True, callback=print_version,
195              expose_value=False, is_eager=True)
196@click.option('--config', '-c',
197              type=click.Path(exists=True, dir_okay=False, readable=True),
198              help="Configuration file to use")
199@click.option('--directory', '-C', default=os.getcwd(),
200              type=click.Path(file_okay=False, readable=True),
201              help="Project directory (default: current directory)")
202@click.option('--on-error', default=None,
203              type=click.Choice(['continue', 'quit', 'terminate']),
204              help="What to do when an error is encountered")
205@click.option('--fetchers', type=click.INT, default=None,
206              help="Maximum simultaneous download tasks")
207@click.option('--builders', type=click.INT, default=None,
208              help="Maximum simultaneous build tasks")
209@click.option('--pushers', type=click.INT, default=None,
210              help="Maximum simultaneous upload tasks")
211@click.option('--network-retries', type=click.INT, default=None,
212              help="Maximum retries for network tasks")
213@click.option('--no-interactive', is_flag=True, default=False,
214              help="Force non interactive mode, otherwise this is automatically decided")
215@click.option('--verbose/--no-verbose', default=None,
216              help="Be extra verbose")
217@click.option('--debug/--no-debug', default=None,
218              help="Print debugging output")
219@click.option('--error-lines', type=click.INT, default=None,
220              help="Maximum number of lines to show from a task log")
221@click.option('--message-lines', type=click.INT, default=None,
222              help="Maximum number of lines to show in a detailed message")
223@click.option('--log-file',
224              type=click.File(mode='w', encoding='UTF-8'),
225              help="A file to store the main log (allows storing the main log while in interactive mode)")
226@click.option('--colors/--no-colors', default=None,
227              help="Force enable/disable ANSI color codes in output")
228@click.option('--strict/--no-strict', default=None, is_flag=True,
229              help="Elements must be rebuilt when their dependencies have changed")
230@click.option('--option', '-o', type=click.Tuple([str, str]), multiple=True, metavar='OPTION VALUE',
231              help="Specify a project option")
232@click.option('--default-mirror', default=None,
233              help="The mirror to fetch from first, before attempting other mirrors")
234@click.pass_context
235def cli(context, **kwargs):
236    """Build and manipulate BuildStream projects
237
238    Most of the main options override options in the
239    user preferences configuration file.
240    """
241
242    from .app import App
243
244    # Create the App, giving it the main arguments
245    context.obj = App.create(dict(kwargs))
246    context.call_on_close(context.obj.cleanup)
247
248
249##################################################################
250#                           Help Command                         #
251##################################################################
252@cli.command(name="help", short_help="Print usage information",
253             context_settings={"help_option_names": []})
254@click.argument("command", nargs=-1, metavar='COMMAND')
255@click.pass_context
256def help_command(ctx, command):
257    """Print usage information about a given command
258    """
259    command_ctx = search_command(command, context=ctx.parent)
260    if not command_ctx:
261        click.echo("Not a valid command: '{} {}'"
262                   .format(ctx.parent.info_name, " ".join(command)), err=True)
263        sys.exit(-1)
264
265    click.echo(command_ctx.command.get_help(command_ctx), err=True)
266
267    # Hint about available sub commands
268    if isinstance(command_ctx.command, click.MultiCommand):
269        detail = " "
270        if command:
271            detail = " {} ".format(" ".join(command))
272        click.echo("\nFor usage on a specific command: {} help{}COMMAND"
273                   .format(ctx.parent.info_name, detail), err=True)
274
275
276##################################################################
277#                           Init Command                         #
278##################################################################
279@cli.command(short_help="Initialize a new BuildStream project")
280@click.option('--project-name', type=click.STRING,
281              help="The project name to use")
282@click.option('--format-version', type=click.INT, default=BST_FORMAT_VERSION,
283              help="The required format version (default: {})".format(BST_FORMAT_VERSION))
284@click.option('--element-path', type=click.Path(), default="elements",
285              help="The subdirectory to store elements in (default: elements)")
286@click.option('--force', '-f', default=False, is_flag=True,
287              help="Allow overwriting an existing project.conf")
288@click.pass_obj
289def init(app, project_name, format_version, element_path, force):
290    """Initialize a new BuildStream project
291
292    Creates a new BuildStream project.conf in the project
293    directory.
294
295    Unless `--project-name` is specified, this will be an
296    interactive session.
297    """
298    app.init_project(project_name, format_version, element_path, force)
299
300
301##################################################################
302#                          Build Command                         #
303##################################################################
304@cli.command(short_help="Build elements in a pipeline")
305@click.option('--all', 'all_', default=False, is_flag=True,
306              help="Build elements that would not be needed for the current build plan")
307@click.option('--track', 'track_', multiple=True,
308              type=click.Path(readable=False),
309              help="Specify elements to track during the build. Can be used "
310                   "repeatedly to specify multiple elements")
311@click.option('--track-all', default=False, is_flag=True,
312              help="Track all elements in the pipeline")
313@click.option('--track-except', multiple=True,
314              type=click.Path(readable=False),
315              help="Except certain dependencies from tracking")
316@click.option('--track-cross-junctions', '-J', default=False, is_flag=True,
317              help="Allow tracking to cross junction boundaries")
318@click.option('--track-save', default=False, is_flag=True,
319              help="Deprecated: This is ignored")
320@click.argument('elements', nargs=-1,
321                type=click.Path(readable=False))
322@click.pass_obj
323def build(app, elements, all_, track_, track_save, track_all, track_except, track_cross_junctions):
324    """Build elements in a pipeline"""
325
326    if (track_except or track_cross_junctions) and not (track_ or track_all):
327        click.echo("ERROR: The --track-except and --track-cross-junctions options "
328                   "can only be used with --track or --track-all", err=True)
329        sys.exit(-1)
330
331    if track_save:
332        click.echo("WARNING: --track-save is deprecated, saving is now unconditional", err=True)
333
334    if track_all:
335        track_ = elements
336
337    with app.initialized(session_name="Build"):
338        app.stream.build(elements,
339                         track_targets=track_,
340                         track_except=track_except,
341                         track_cross_junctions=track_cross_junctions,
342                         build_all=all_)
343
344
345##################################################################
346#                          Fetch Command                         #
347##################################################################
348@cli.command(short_help="Fetch sources in a pipeline")
349@click.option('--except', 'except_', multiple=True,
350              type=click.Path(readable=False),
351              help="Except certain dependencies from fetching")
352@click.option('--deps', '-d', default='plan',
353              type=click.Choice(['none', 'plan', 'all']),
354              help='The dependencies to fetch (default: plan)')
355@click.option('--track', 'track_', default=False, is_flag=True,
356              help="Track new source references before fetching")
357@click.option('--track-cross-junctions', '-J', default=False, is_flag=True,
358              help="Allow tracking to cross junction boundaries")
359@click.argument('elements', nargs=-1,
360                type=click.Path(readable=False))
361@click.pass_obj
362def fetch(app, elements, deps, track_, except_, track_cross_junctions):
363    """Fetch sources required to build the pipeline
364
365    By default this will only try to fetch sources which are
366    required for the build plan of the specified target element,
367    omitting sources for any elements which are already built
368    and available in the artifact cache.
369
370    Specify `--deps` to control which sources to fetch:
371
372    \b
373        none:  No dependencies, just the element itself
374        plan:  Only dependencies required for the build plan
375        all:   All dependencies
376    """
377    from .._pipeline import PipelineSelection
378
379    if track_cross_junctions and not track_:
380        click.echo("ERROR: The --track-cross-junctions option can only be used with --track", err=True)
381        sys.exit(-1)
382
383    if track_ and deps == PipelineSelection.PLAN:
384        click.echo("WARNING: --track specified for tracking of a build plan\n\n"
385                   "Since tracking modifies the build plan, all elements will be tracked.", err=True)
386        deps = PipelineSelection.ALL
387
388    with app.initialized(session_name="Fetch"):
389        app.stream.fetch(elements,
390                         selection=deps,
391                         except_targets=except_,
392                         track_targets=track_,
393                         track_cross_junctions=track_cross_junctions)
394
395
396##################################################################
397#                          Track Command                         #
398##################################################################
399@cli.command(short_help="Track new source references")
400@click.option('--except', 'except_', multiple=True,
401              type=click.Path(readable=False),
402              help="Except certain dependencies from tracking")
403@click.option('--deps', '-d', default='none',
404              type=click.Choice(['none', 'all']),
405              help='The dependencies to track (default: none)')
406@click.option('--cross-junctions', '-J', default=False, is_flag=True,
407              help="Allow crossing junction boundaries")
408@click.argument('elements', nargs=-1,
409                type=click.Path(readable=False))
410@click.pass_obj
411def track(app, elements, deps, except_, cross_junctions):
412    """Consults the specified tracking branches for new versions available
413    to build and updates the project with any newly available references.
414
415    By default this will track just the specified element, but you can also
416    update a whole tree of dependencies in one go.
417
418    Specify `--deps` to control which sources to track:
419
420    \b
421        none:  No dependencies, just the specified elements
422        all:   All dependencies of all specified elements
423    """
424    with app.initialized(session_name="Track"):
425        # Substitute 'none' for 'redirect' so that element redirections
426        # will be done
427        if deps == 'none':
428            deps = 'redirect'
429        app.stream.track(elements,
430                         selection=deps,
431                         except_targets=except_,
432                         cross_junctions=cross_junctions)
433
434
435##################################################################
436#                           Pull Command                         #
437##################################################################
438@cli.command(short_help="Pull a built artifact")
439@click.option('--deps', '-d', default='none',
440              type=click.Choice(['none', 'all']),
441              help='The dependency artifacts to pull (default: none)')
442@click.option('--remote', '-r',
443              help="The URL of the remote cache (defaults to the first configured cache)")
444@click.argument('elements', nargs=-1,
445                type=click.Path(readable=False))
446@click.pass_obj
447def pull(app, elements, deps, remote):
448    """Pull a built artifact from the configured remote artifact cache.
449
450    By default the artifact will be pulled one of the configured caches
451    if possible, following the usual priority order. If the `--remote` flag
452    is given, only the specified cache will be queried.
453
454    Specify `--deps` to control which artifacts to pull:
455
456    \b
457        none:  No dependencies, just the element itself
458        all:   All dependencies
459    """
460    with app.initialized(session_name="Pull"):
461        app.stream.pull(elements, selection=deps, remote=remote)
462
463
464##################################################################
465#                           Push Command                         #
466##################################################################
467@cli.command(short_help="Push a built artifact")
468@click.option('--deps', '-d', default='none',
469              type=click.Choice(['none', 'all']),
470              help='The dependencies to push (default: none)')
471@click.option('--remote', '-r', default=None,
472              help="The URL of the remote cache (defaults to the first configured cache)")
473@click.argument('elements', nargs=-1,
474                type=click.Path(readable=False))
475@click.pass_obj
476def push(app, elements, deps, remote):
477    """Push a built artifact to a remote artifact cache.
478
479    The default destination is the highest priority configured cache. You can
480    override this by passing a different cache URL with the `--remote` flag.
481
482    Specify `--deps` to control which artifacts to push:
483
484    \b
485        none:  No dependencies, just the element itself
486        all:   All dependencies
487    """
488    with app.initialized(session_name="Push"):
489        app.stream.push(elements, selection=deps, remote=remote)
490
491
492##################################################################
493#                           Show Command                         #
494##################################################################
495@cli.command(short_help="Show elements in the pipeline")
496@click.option('--except', 'except_', multiple=True,
497              type=click.Path(readable=False),
498              help="Except certain dependencies")
499@click.option('--deps', '-d', default='all',
500              type=click.Choice(['none', 'plan', 'run', 'build', 'all']),
501              help='The dependencies to show (default: all)')
502@click.option('--order', default="stage",
503              type=click.Choice(['stage', 'alpha']),
504              help='Staging or alphabetic ordering of dependencies')
505@click.option('--format', '-f', 'format_', metavar='FORMAT', default=None,
506              type=click.STRING,
507              help='Format string for each element')
508@click.argument('elements', nargs=-1,
509                type=click.Path(readable=False))
510@click.pass_obj
511def show(app, elements, deps, except_, order, format_):
512    """Show elements in the pipeline
513
514    By default this will show all of the dependencies of the
515    specified target element.
516
517    Specify `--deps` to control which elements to show:
518
519    \b
520        none:  No dependencies, just the element itself
521        plan:  Dependencies required for a build plan
522        run:   Runtime dependencies, including the element itself
523        build: Build time dependencies, excluding the element itself
524        all:   All dependencies
525
526    \b
527    FORMAT
528    ~~~~~~
529    The --format option controls what should be printed for each element,
530    the following symbols can be used in the format string:
531
532    \b
533        %{name}           The element name
534        %{key}            The abbreviated cache key (if all sources are consistent)
535        %{full-key}       The full cache key (if all sources are consistent)
536        %{state}          cached, buildable, waiting or inconsistent
537        %{config}         The element configuration
538        %{vars}           Variable configuration
539        %{env}            Environment settings
540        %{public}         Public domain data
541        %{workspaced}     If the element is workspaced
542        %{workspace-dirs} A list of workspace directories
543
544    The value of the %{symbol} without the leading '%' character is understood
545    as a pythonic formatting string, so python formatting features apply,
546    examle:
547
548    \b
549        bst show target.bst --format \\
550            'Name: %{name: ^20} Key: %{key: ^8} State: %{state}'
551
552    If you want to use a newline in a format string in bash, use the '$' modifier:
553
554    \b
555        bst show target.bst --format \\
556            $'---------- %{name} ----------\\n%{vars}'
557    """
558    with app.initialized():
559        dependencies = app.stream.load_selection(elements,
560                                                 selection=deps,
561                                                 except_targets=except_)
562
563        if order == "alpha":
564            dependencies = sorted(dependencies)
565
566        if not format_:
567            format_ = app.context.log_element_format
568
569        report = app.logger.show_pipeline(dependencies, format_)
570        click.echo(report, color=app.colors)
571
572
573##################################################################
574#                          Shell Command                         #
575##################################################################
576@cli.command(short_help="Shell into an element's sandbox environment")
577@click.option('--build', '-b', 'build_', is_flag=True, default=False,
578              help='Stage dependencies and sources to build')
579@click.option('--sysroot', '-s', default=None,
580              type=click.Path(exists=True, file_okay=False, readable=True),
581              help="An existing sysroot")
582@click.option('--mount', type=click.Tuple([click.Path(exists=True), str]), multiple=True,
583              metavar='HOSTPATH PATH',
584              help="Mount a file or directory into the sandbox")
585@click.option('--isolate', is_flag=True, default=False,
586              help='Create an isolated build sandbox')
587@click.argument('element',
588                type=click.Path(readable=False))
589@click.argument('command', type=click.STRING, nargs=-1)
590@click.pass_obj
591def shell(app, element, sysroot, mount, isolate, build_, command):
592    """Run a command in the target element's sandbox environment
593
594    This will stage a temporary sysroot for running the target
595    element, assuming it has already been built and all required
596    artifacts are in the local cache.
597
598    Use the --build option to create a temporary sysroot for
599    building the element instead.
600
601    Use the --sysroot option with an existing failed build
602    directory or with a checkout of the given target, in order
603    to use a specific sysroot.
604
605    If no COMMAND is specified, the default is to attempt
606    to run an interactive shell.
607    """
608    from ..element import Scope
609    from .._project import HostMount
610    from .._pipeline import PipelineSelection
611
612    if build_:
613        scope = Scope.BUILD
614    else:
615        scope = Scope.RUN
616
617    with app.initialized():
618        dependencies = app.stream.load_selection((element,), selection=PipelineSelection.NONE)
619        element = dependencies[0]
620        prompt = app.shell_prompt(element)
621        mounts = [
622            HostMount(path, host_path)
623            for host_path, path in mount
624        ]
625        try:
626            exitcode = app.stream.shell(element, scope, prompt,
627                                        directory=sysroot,
628                                        mounts=mounts,
629                                        isolate=isolate,
630                                        command=command)
631        except BstError as e:
632            raise AppError("Error launching shell: {}".format(e), detail=e.detail) from e
633
634    # If there were no errors, we return the shell's exit code here.
635    sys.exit(exitcode)
636
637
638##################################################################
639#                        Checkout Command                        #
640##################################################################
641@cli.command(short_help="Checkout a built artifact")
642@click.option('--force', '-f', default=False, is_flag=True,
643              help="Allow files to be overwritten")
644@click.option('--deps', '-d', default='run',
645              type=click.Choice(['run', 'none']),
646              help='The dependencies to checkout (default: run)')
647@click.option('--integrate/--no-integrate', default=True, is_flag=True,
648              help="Whether to run integration commands")
649@click.option('--hardlinks', default=False, is_flag=True,
650              help="Checkout hardlinks instead of copies (handle with care)")
651@click.option('--tar', default=False, is_flag=True,
652              help="Create a tarball from the artifact contents instead "
653                   "of a file tree. If LOCATION is '-', the tarball "
654                   "will be dumped to the standard output.")
655@click.argument('element',
656                type=click.Path(readable=False))
657@click.argument('location', type=click.Path())
658@click.pass_obj
659def checkout(app, element, location, force, deps, integrate, hardlinks, tar):
660    """Checkout a built artifact to the specified location
661    """
662
663    if hardlinks and tar:
664        click.echo("ERROR: options --hardlinks and --tar conflict", err=True)
665        sys.exit(-1)
666
667    with app.initialized():
668        app.stream.checkout(element,
669                            location=location,
670                            force=force,
671                            deps=deps,
672                            integrate=integrate,
673                            hardlinks=hardlinks,
674                            tar=tar)
675
676
677##################################################################
678#                      Workspace Command                         #
679##################################################################
680@cli.group(short_help="Manipulate developer workspaces")
681def workspace():
682    """Manipulate developer workspaces"""
683    pass
684
685
686##################################################################
687#                     Workspace Open Command                     #
688##################################################################
689@workspace.command(name='open', short_help="Open a new workspace")
690@click.option('--no-checkout', default=False, is_flag=True,
691              help="Do not checkout the source, only link to the given directory")
692@click.option('--force', '-f', default=False, is_flag=True,
693              help="Overwrite files existing in checkout directory")
694@click.option('--track', 'track_', default=False, is_flag=True,
695              help="Track and fetch new source references before checking out the workspace")
696@click.argument('element',
697                type=click.Path(readable=False))
698@click.argument('directory', type=click.Path(file_okay=False))
699@click.pass_obj
700def workspace_open(app, no_checkout, force, track_, element, directory):
701    """Open a workspace for manual source modification"""
702
703    if os.path.exists(directory):
704
705        if not os.path.isdir(directory):
706            click.echo("Checkout directory is not a directory: {}".format(directory), err=True)
707            sys.exit(-1)
708
709        if not (no_checkout or force) and os.listdir(directory):
710            click.echo("Checkout directory is not empty: {}".format(directory), err=True)
711            sys.exit(-1)
712
713    with app.initialized():
714        app.stream.workspace_open(element, directory,
715                                  no_checkout=no_checkout,
716                                  track_first=track_,
717                                  force=force)
718
719
720##################################################################
721#                     Workspace Close Command                    #
722##################################################################
723@workspace.command(name='close', short_help="Close workspaces")
724@click.option('--remove-dir', default=False, is_flag=True,
725              help="Remove the path that contains the closed workspace")
726@click.option('--all', '-a', 'all_', default=False, is_flag=True,
727              help="Close all open workspaces")
728@click.argument('elements', nargs=-1,
729                type=click.Path(readable=False))
730@click.pass_obj
731def workspace_close(app, remove_dir, all_, elements):
732    """Close a workspace"""
733
734    if not (all_ or elements):
735        click.echo('ERROR: no elements specified', err=True)
736        sys.exit(-1)
737
738    with app.initialized():
739
740        # Early exit if we specified `all` and there are no workspaces
741        if all_ and not app.stream.workspace_exists():
742            click.echo('No open workspaces to close', err=True)
743            sys.exit(0)
744
745        if all_:
746            elements = [element_name for element_name, _ in app.context.get_workspaces().list()]
747
748        elements = app.stream.redirect_element_names(elements)
749
750        # Check that the workspaces in question exist
751        nonexisting = []
752        for element_name in elements:
753            if not app.stream.workspace_exists(element_name):
754                nonexisting.append(element_name)
755        if nonexisting:
756            raise AppError("Workspace does not exist", detail="\n".join(nonexisting))
757
758        if app.interactive and remove_dir:
759            if not click.confirm('This will remove all your changes, are you sure?'):
760                click.echo('Aborting', err=True)
761                sys.exit(-1)
762
763        for element_name in elements:
764            app.stream.workspace_close(element_name, remove_dir=remove_dir)
765
766
767##################################################################
768#                     Workspace Reset Command                    #
769##################################################################
770@workspace.command(name='reset', short_help="Reset a workspace to its original state")
771@click.option('--soft', default=False, is_flag=True,
772              help="Reset workspace state without affecting its contents")
773@click.option('--track', 'track_', default=False, is_flag=True,
774              help="Track and fetch the latest source before resetting")
775@click.option('--all', '-a', 'all_', default=False, is_flag=True,
776              help="Reset all open workspaces")
777@click.argument('elements', nargs=-1,
778                type=click.Path(readable=False))
779@click.pass_obj
780def workspace_reset(app, soft, track_, all_, elements):
781    """Reset a workspace to its original state"""
782
783    # Check that the workspaces in question exist
784    with app.initialized():
785
786        if not (all_ or elements):
787            raise AppError('No elements specified to reset')
788
789        if all_ and not app.stream.workspace_exists():
790            raise AppError("No open workspaces to reset")
791
792        if app.interactive and not soft:
793            if not click.confirm('This will remove all your changes, are you sure?'):
794                click.echo('Aborting', err=True)
795                sys.exit(-1)
796
797        if all_:
798            elements = tuple(element_name for element_name, _ in app.context.get_workspaces().list())
799
800        app.stream.workspace_reset(elements, soft=soft, track_first=track_)
801
802
803##################################################################
804#                     Workspace List Command                     #
805##################################################################
806@workspace.command(name='list', short_help="List open workspaces")
807@click.pass_obj
808def workspace_list(app):
809    """List open workspaces"""
810
811    with app.initialized():
812        app.stream.workspace_list()
813
814
815##################################################################
816#                     Source Bundle Command                      #
817##################################################################
818@cli.command(name="source-bundle", short_help="Produce a build bundle to be manually executed")
819@click.option('--except', 'except_', multiple=True,
820              type=click.Path(readable=False),
821              help="Elements to except from the tarball")
822@click.option('--compression', default='gz',
823              type=click.Choice(['none', 'gz', 'bz2', 'xz']),
824              help="Compress the tar file using the given algorithm.")
825@click.option('--track', 'track_', default=False, is_flag=True,
826              help="Track new source references before bundling")
827@click.option('--force', '-f', default=False, is_flag=True,
828              help="Overwrite an existing tarball")
829@click.option('--directory', default=os.getcwd(),
830              help="The directory to write the tarball to")
831@click.argument('element',
832                type=click.Path(readable=False))
833@click.pass_obj
834def source_bundle(app, element, force, directory,
835                  track_, compression, except_):
836    """Produce a source bundle to be manually executed
837    """
838    with app.initialized():
839        app.stream.source_bundle(element, directory,
840                                 track_first=track_,
841                                 force=force,
842                                 compression=compression,
843                                 except_targets=except_)
844