From 325ac03082cb499f0e9b6585888a29647cfe890a Mon Sep 17 00:00:00 2001
From: Jonathan Maw <jonathan.maw@codethink.co.uk>
Date: Mon, 19 Nov 2018 17:26:59 +0000
Subject: [PATCH] WIP: Make specifying elements optional in bst commands

Known issues:
* `bst shell` works, but `bst shell COMMANDS...` doesn't, because click
  has no way of separating optional args from variable-length args.
* `bst checkout`'s usage string marks LOCATION as an optional argument.
  Because click gets confused if there's an optional argument before a
  mandatory argument, I had to mark LOCATION as optional internally.
* `bst workspace open` makes no sense with element being optional, so
  I skipped it.
* `bst workspace close` will probably need to be revisited when multiple
  projects can own one workspace.
* `bst workspace reset` will happily delete the directory you're
  currently in, requiring you to `cd $PWD` to see the contents of your
  directory.
  I could exclude the top-level directory of the workspace being
  deleted, but it is entirely valid to run workspace commands from deeper
  in the workspace.
* `bst source-bundle` does not work if a workspace is open at all, and
  according to #672 is scoped for deprecation, so I have left it alone.
---
 buildstream/_context.py      |  8 +++-
 buildstream/_frontend/app.py | 18 ++++++++-
 buildstream/_frontend/cli.py | 78 +++++++++++++++++++++++++++++++++---
 3 files changed, 95 insertions(+), 9 deletions(-)

diff --git a/buildstream/_context.py b/buildstream/_context.py
index 57a4749217..ef4a7a1de8 100644
--- a/buildstream/_context.py
+++ b/buildstream/_context.py
@@ -47,9 +47,13 @@ from .plugin import _plugin_lookup
 # verbosity levels and basically anything pertaining to the context
 # in which BuildStream was invoked.
 #
+# Args:
+#    workspace_project_cache (WorkspaceProjectCache): A WorkspaceProjectCache
+#        for this invocation
+#
 class Context():
 
-    def __init__(self):
+    def __init__(self, workspace_project_cache=None):
 
         # Filename indicating which configuration file was used, or None for the defaults
         self.config_origin = None
@@ -144,7 +148,7 @@ class Context():
         self._projects = []
         self._project_overrides = {}
         self._workspaces = None
-        self._workspace_project_cache = WorkspaceProjectCache()
+        self._workspace_project_cache = workspace_project_cache or WorkspaceProjectCache()
         self._log_handle = None
         self._log_filename = None
         self._cascache = None
diff --git a/buildstream/_frontend/app.py b/buildstream/_frontend/app.py
index 4094eec17b..e3a7603945 100644
--- a/buildstream/_frontend/app.py
+++ b/buildstream/_frontend/app.py
@@ -39,6 +39,7 @@ from .._stream import Stream
 from .._versions import BST_FORMAT_VERSION
 from .. import _yaml
 from .._scheduler import ElementJob
+from .._workspaces import WorkspaceProjectCache
 
 # Import frontend assets
 from . import Profile, LogLine, Status
@@ -79,6 +80,7 @@ class App():
         self._fail_messages = {}           # Failure messages by unique plugin id
         self._interactive_failures = None  # Whether to handle failures interactively
         self._started = False              # Whether a session has started
+        self._workspace_project_cache = WorkspaceProjectCache()  # A collection of workspace local data
 
         # UI Colors Profiles
         self._content_profile = Profile(fg='yellow')
@@ -164,7 +166,7 @@ class App():
         # Load the Context
         #
         try:
-            self.context = Context()
+            self.context = Context(self._workspace_project_cache)
             self.context.load(config)
         except BstError as e:
             self._error_exit(e, "Error loading user configuration")
@@ -402,6 +404,20 @@ class App():
         if self.stream:
             self.stream.cleanup()
 
+    # guess_element()
+    #
+    # Attempts to interpret which element the user intended to run commands on
+    #
+    # Returns:
+    #    (str) The name of the element, or an empty string
+    def guess_element(self):
+        directory = self._main_options['directory']
+        workspace_project = self._workspace_project_cache.get(directory)
+        if workspace_project:
+            return workspace_project.get_default_element()
+        else:
+            return ""
+
     ############################################################
     #                   Abstract Class Methods                 #
     ############################################################
diff --git a/buildstream/_frontend/cli.py b/buildstream/_frontend/cli.py
index 04befb3fdd..0d22f87dfd 100644
--- a/buildstream/_frontend/cli.py
+++ b/buildstream/_frontend/cli.py
@@ -312,6 +312,12 @@ def build(app, elements, all_, track_, track_save, track_all, track_except, trac
     if track_save:
         click.echo("WARNING: --track-save is deprecated, saving is now unconditional", err=True)
 
+    if not all_ and not elements:
+        # Attempt to divine the element from the workspace you're in
+        guessed_target = app.guess_element()
+        if guessed_target:
+            elements = (guessed_target,)
+
     if track_all:
         track_ = elements
 
@@ -366,6 +372,12 @@ def fetch(app, elements, deps, track_, except_, track_cross_junctions):
                    "Since tracking modifies the build plan, all elements will be tracked.", err=True)
         deps = PipelineSelection.ALL
 
+    if not elements:
+        # Attempt to divine the element from the workspace you're in
+        guessed_target = app.guess_element()
+        if guessed_target:
+            elements = (guessed_target,)
+
     with app.initialized(session_name="Fetch"):
         app.stream.fetch(elements,
                          selection=deps,
@@ -402,6 +414,12 @@ def track(app, elements, deps, except_, cross_junctions):
         none:  No dependencies, just the specified elements
         all:   All dependencies of all specified elements
     """
+    if not elements:
+        # Attempt to divine the element from the workspace you're in
+        guessed_target = app.guess_element()
+        if guessed_target:
+            elements = (guessed_target,)
+
     with app.initialized(session_name="Track"):
         # Substitute 'none' for 'redirect' so that element redirections
         # will be done
@@ -438,6 +456,12 @@ def pull(app, elements, deps, remote):
         none:  No dependencies, just the element itself
         all:   All dependencies
     """
+    if not elements:
+        # Attempt to divine the element from the workspace you're in
+        guessed_target = app.guess_element()
+        if guessed_target:
+            elements = (guessed_target,)
+
     with app.initialized(session_name="Pull"):
         app.stream.pull(elements, selection=deps, remote=remote)
 
@@ -466,6 +490,11 @@ def push(app, elements, deps, remote):
         none:  No dependencies, just the element itself
         all:   All dependencies
     """
+    if not elements:
+        # Attempt to divine the element from the workspace you're in
+        guessed_target = app.guess_element()
+        if guessed_target:
+            elements = (guessed_target,)
     with app.initialized(session_name="Push"):
         app.stream.push(elements, selection=deps, remote=remote)
 
@@ -536,6 +565,12 @@ def show(app, elements, deps, except_, order, format_):
         bst show target.bst --format \\
             $'---------- %{name} ----------\\n%{vars}'
     """
+    if not elements:
+        # Attempt to divine the element from the workspace you're in
+        guessed_target = app.guess_element()
+        if guessed_target:
+            elements = (guessed_target,)
+
     with app.initialized():
         dependencies = app.stream.load_selection(elements,
                                                  selection=deps,
@@ -565,7 +600,7 @@ def show(app, elements, deps, except_, order, format_):
               help="Mount a file or directory into the sandbox")
 @click.option('--isolate', is_flag=True, default=False,
               help='Create an isolated build sandbox')
-@click.argument('element',
+@click.argument('element', required=False,
                 type=click.Path(readable=False))
 @click.argument('command', type=click.STRING, nargs=-1)
 @click.pass_obj
@@ -596,6 +631,14 @@ def shell(app, element, sysroot, mount, isolate, build_, command):
         scope = Scope.RUN
 
     with app.initialized():
+        if not element:
+            # Attempt to divine the element from the workspace you're in
+            guessed_target = app.guess_element()
+            if guessed_target:
+                element = guessed_target
+            else:
+                raise AppError('Error: Missing argument "ELEMENT".')
+
         dependencies = app.stream.load_selection((element,), selection=PipelineSelection.NONE)
         element = dependencies[0]
         prompt = app.shell_prompt(element)
@@ -633,14 +676,27 @@ def shell(app, element, sysroot, mount, isolate, build_, command):
               help="Create a tarball from the artifact contents instead "
                    "of a file tree. If LOCATION is '-', the tarball "
                    "will be dumped to the standard output.")
-@click.argument('element',
+@click.argument('element', required=False,
                 type=click.Path(readable=False))
-@click.argument('location', type=click.Path())
+@click.argument('location', type=click.Path(), required=False)
 @click.pass_obj
 def checkout(app, element, location, force, deps, integrate, hardlinks, tar):
     """Checkout a built artifact to the specified location
     """
 
+    if not element and not location:
+        click.echo("ERROR: LOCATION is not specified", err=True)
+        sys.exit(-1)
+
+    if element and not location:
+        # Nasty hack to get around click's optional args
+        location = element
+        element = app.guess_element()
+
+    if not element:
+        click.echo("ERROR: ELEMENT is not specified", err=True)
+        sys.exit(-1)
+
     if hardlinks and tar:
         click.echo("ERROR: options --hardlinks and --tar conflict", err=True)
         sys.exit(-1)
@@ -732,8 +788,14 @@ def workspace_close(app, remove_dir, all_, elements):
     """Close a workspace"""
 
     if not (all_ or elements):
-        click.echo('ERROR: no elements specified', err=True)
-        sys.exit(-1)
+        # NOTE: I may need to revisit this when implementing multiple projects
+        # opening one workspace.
+        element = app.guess_element()
+        if element:
+            elements = (element,)
+        else:
+            click.echo('ERROR: no elements specified', err=True)
+            sys.exit(-1)
 
     with app.initialized():
 
@@ -791,7 +853,11 @@ def workspace_reset(app, soft, track_, all_, elements):
     with app.initialized():
 
         if not (all_ or elements):
-            raise AppError('No elements specified to reset')
+            element = app.guess_element()
+            if element:
+                elements = (element,)
+            else:
+                raise AppError('No elements specified to reset')
 
         if all_ and not app.stream.workspace_exists():
             raise AppError("No open workspaces to reset")
-- 
GitLab