Repository: spack/spack

Issue:
uninstall a package with its dependencies if they are not used elsewhere
i think it would be good to be able to uninstall not only a single package, but everything within it's graph if it is not used elsewhere. One could start from the root of the DAG and traverse it to figure out what can be removed.


Discussion:
@adamjstewart @tgamblin Would an option like:
```
spack uninstall --autoremove [<spec>]
```
be good as UI for this one?
That sounds good to me. The question is whether or not to check if the unused dependency was explicitly installed or not.
The logic imho should be:
```console
$ spack uninstall --autoremove
```
uninstalls all the packages that are not dependencies of something installed explicitly. The command:
```console
$ spack uninstall --autoremove <spec>
```
first uninstalls spec, then applies the logic above.

Resolving patch:
diff --git a/lib/spack/spack/cmd/gc.py b/lib/spack/spack/cmd/gc.py
new file mode 100644
--- /dev/null
+++ b/lib/spack/spack/cmd/gc.py
@@ -0,0 +1,47 @@
+# Copyright 2013-2020 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+import llnl.util.tty as tty
+
+import spack.cmd.common.arguments
+import spack.cmd.uninstall
+import spack.environment
+import spack.store
+
+description = "remove specs that are now no longer needed"
+section = "build"
+level = "short"
+
+
+def setup_parser(subparser):
+    spack.cmd.common.arguments.add_common_arguments(subparser, ['yes_to_all'])
+
+
+def gc(parser, args):
+    specs = spack.store.db.unused_specs
+
+    # Restrict garbage collection to the active environment
+    # speculating over roots that are yet to be installed
+    env = spack.environment.get_env(args=None, cmd_name='gc')
+    if env:
+        msg = 'Restricting the garbage collection to the "{0}" environment'
+        tty.msg(msg.format(env.name))
+        env.concretize()
+        roots = [s for s in env.roots()]
+        all_hashes = set([s.dag_hash() for r in roots for s in r.traverse()])
+        lr_hashes = set([s.dag_hash() for r in roots
+                         for s in r.traverse(deptype=('link', 'run'))])
+        maybe_to_be_removed = all_hashes - lr_hashes
+        specs = [s for s in specs if s.dag_hash() in maybe_to_be_removed]
+
+    if not specs:
+        msg = "There are no unused specs. Spack's store is clean."
+        tty.msg(msg)
+        return
+
+    if not args.yes_to_all:
+        spack.cmd.uninstall.confirm_removal(specs)
+
+    spack.cmd.uninstall.do_uninstall(None, specs, force=False)
diff --git a/lib/spack/spack/cmd/uninstall.py b/lib/spack/spack/cmd/uninstall.py
--- a/lib/spack/spack/cmd/uninstall.py
+++ b/lib/spack/spack/cmd/uninstall.py
@@ -6,6 +6,7 @@
 from __future__ import print_function
 
 import argparse
+import sys
 
 import spack.cmd
 import spack.environment as ev
@@ -31,8 +32,8 @@
 # Arguments for display_specs when we find ambiguity
 display_args = {
     'long': True,
-    'show_flags': True,
-    'variants': True,
+    'show_flags': False,
+    'variants': False,
     'indent': 4,
 }
 
@@ -324,11 +325,7 @@ def uninstall_specs(args, specs):
         return
 
     if not args.yes_to_all:
-        tty.msg('The following packages will be uninstalled:\n')
-        spack.cmd.display_specs(anything_to_do, **display_args)
-        answer = tty.get_yes_or_no('Do you want to proceed?', default=False)
-        if not answer:
-            tty.die('Will not uninstall any packages.')
+        confirm_removal(anything_to_do)
 
     # just force-remove things in the remove list
     for spec in remove_list:
@@ -338,6 +335,21 @@ def uninstall_specs(args, specs):
     do_uninstall(env, uninstall_list, args.force)
 
 
+def confirm_removal(specs):
+    """Display the list of specs to be removed and ask for confirmation.
+
+    Args:
+        specs (list): specs to be removed
+    """
+    tty.msg('The following packages will be uninstalled:\n')
+    spack.cmd.display_specs(specs, **display_args)
+    print('')
+    answer = tty.get_yes_or_no('Do you want to proceed?', default=False)
+    if not answer:
+        tty.msg('Aborting uninstallation')
+        sys.exit(0)
+
+
 def uninstall(parser, args):
     if not args.packages and not args.all:
         tty.die('uninstall requires at least one package argument.',
diff --git a/lib/spack/spack/database.py b/lib/spack/spack/database.py
--- a/lib/spack/spack/database.py
+++ b/lib/spack/spack/database.py
@@ -1295,6 +1295,30 @@ def missing(self, spec):
         upstream, record = self.query_by_spec_hash(key)
         return record and not record.installed
 
+    @property
+    def unused_specs(self):
+        """Return all the specs that are currently installed but not needed
+        at runtime to satisfy user's requests.
+
+        Specs in the return list are those which are not either:
+            1. Installed on an explicit user request
+            2. Installed as a "run" or "link" dependency (even transitive) of
+               a spec at point 1.
+        """
+        needed, visited = set(), set()
+        with self.read_transaction():
+            for key, rec in self._data.items():
+                if rec.explicit:
+                    # recycle `visited` across calls to avoid
+                    # redundantly traversing
+                    for spec in rec.spec.traverse(visited=visited):
+                        needed.add(spec.dag_hash())
+
+            unused = [rec.spec for key, rec in self._data.items()
+                      if key not in needed and rec.installed]
+
+        return unused
+
 
 class UpstreamDatabaseLockingError(SpackError):
     """Raised when an operation would need to lock an upstream database"""


Test patch:
diff --git a/lib/spack/spack/test/cmd/gc.py b/lib/spack/spack/test/cmd/gc.py
new file mode 100644
--- /dev/null
+++ b/lib/spack/spack/test/cmd/gc.py
@@ -0,0 +1,44 @@
+# Copyright 2013-2020 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+import pytest
+
+import spack.environment as ev
+import spack.spec
+import spack.main
+
+gc = spack.main.SpackCommand('gc')
+
+
+@pytest.mark.db
+def test_no_packages_to_remove(config, mutable_database, capsys):
+    with capsys.disabled():
+        output = gc('-y')
+    assert 'There are no unused specs.' in output
+
+
+@pytest.mark.db
+def test_packages_are_removed(config, mutable_database, capsys):
+    s = spack.spec.Spec('simple-inheritance')
+    s.concretize()
+    s.package.do_install(fake=True, explicit=True)
+    with capsys.disabled():
+        output = gc('-y')
+    assert 'Successfully uninstalled cmake' in output
+
+
+@pytest.mark.db
+def test_gc_with_environment(config, mutable_database, capsys):
+    s = spack.spec.Spec('simple-inheritance')
+    s.concretize()
+    s.package.do_install(fake=True, explicit=True)
+
+    e = ev.create('test_gc')
+    e.add('cmake')
+    with e:
+        with capsys.disabled():
+            output = gc('-y')
+    assert 'Restricting the garbage collection' in output
+    assert 'There are no unused specs' in output
diff --git a/lib/spack/spack/test/database.py b/lib/spack/spack/test/database.py
--- a/lib/spack/spack/test/database.py
+++ b/lib/spack/spack/test/database.py
@@ -706,3 +706,14 @@ def test_uninstall_by_spec(mutable_database):
             else:
                 mutable_database.remove(spec)
     assert len(mutable_database.query()) == 0
+
+
+def test_query_unused_specs(mutable_database):
+    # This spec installs a fake cmake as a build only dependency
+    s = spack.spec.Spec('simple-inheritance')
+    s.concretize()
+    s.package.do_install(fake=True, explicit=True)
+
+    unused = spack.store.db.unused_specs
+    assert len(unused) == 1
+    assert unused[0].name == 'cmake'