Repository: ARMmbed/yotta

Issue:
c:\ytexe yt link does not install module
## Problem 
To add a local library module to a executable module it requires 3 steps
`c:\ytlib yt link`
`c:\ytexe yt link ytlib`
If you run `yt ls` at this point there is no indication that `ytlib` is linked into the project, you must either `yt install` or `yt build` to get `ytlib` added to your module.json file
`c:\ytexe yt install ytlib`
At this point you can now see `ytlib` in a `yt ls` command output.

### example
I'm a developer developing locally. I want to add simplelog to my project to help me debug and maybe tweak a few pretty print settings in simplelog for fun. So I make the yotta executable `ytexe`. I clone the simplelog repo to my local machine so they are both at my root directory. Now to add simplelog to my project I must 
`C:\simplelog yt link`
`C:\ytexe yt link simplelog`
at this point I have added simplelog calls into my code and run `yt build`, which fails, because the simplelog module has not been added to my module.json, nor does it show up in a `yt ls` command.
I must run `yt install simplelog`. 

This may be expected behaviour, and maybe we dont want to change it, but at the very least it makes for a bad user experience. 

## Solution
1) have a `yt link <absolute file path>` command that takes care of all these steps
or
2) have `c:\ytexe yt link ytlib` add the module to the module.json or otherwise make it obvious that it is added to the project, currently there is no feedback.
or
3) when a user runs `c:\ytexe yt link ytlib` give them a feedback message telling them they need to `yt install ytlib` to finish adding the module. I think this solution is the least optimum because it requires 3 steps instead of 1, but it is the miminum required for user interaction. 

Discussion:

Patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..0150ea4
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,5 @@
+[run]
+parallel=True
+concurrency=multiprocessing
+include=./yotta/*
+
diff --git a/tox.ini b/tox.ini
index 20af1fb..06075d3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -6,14 +6,16 @@ deps=
     cython
     pylint
     coverage
-setenv=
-    COVERAGE_PROCESS_START = {toxinidir}/.coveragerc
+passenv=
+    SSH_AUTH_SOCK
 commands=
     pip install .
-    coverage erase
-    coverage run --parallel-mode setup.py test
-    coverage combine
-    coverage report --include="yotta/*"
+    python setup.py test
+    # disable coverage for now: subprocesses aren't being combined correctly
+    # coverage erase
+    # coverage run --parallel-mode setup.py test
+    # coverage combine
+    # coverage report --include="yotta/*"
     py27: pylint ./yotta
     py33: pylint ./yotta
     py34: pylint ./yotta
diff --git a/yotta/lib/validate.py b/yotta/lib/validate.py
index 1ff5b8e..ed38de8 100644
--- a/yotta/lib/validate.py
+++ b/yotta/lib/validate.py
@@ -18,10 +18,12 @@ import pack
 
 Source_Dir_Regex = re.compile('^[a-z0-9_-]*$')
 Source_Dir_Invalid_Regex = re.compile('[^a-z0-9_-]*')
-Component_Name_Regex = re.compile('^[a-z0-9-]*$')
 Component_Name_Replace_With_Dash = re.compile('[^a-z0-9]+')
 Looks_Like_An_Email = re.compile('^[^@]+@[^@]+\.[^@]+$')
 
+Component_Name_Regex = r'^[a-z]+[a-z0-9-]*$'
+Target_Name_Regex = r'^[a-z]+[a-z0-9+-]*$'
+
 # return an error string describing the validation failure, or None if there is
 # no error
 def sourceDirValidationError(dirname, component_name):
@@ -41,10 +43,15 @@ def sourceDirValidationError(dirname, component_name):
         return None
 
 def componentNameValidationError(component_name):
-    if not Component_Name_Regex.match(component_name):
+    if not re.match(Component_Name_Regex, component_name):
         return 'Module name "%s" is invalid - must contain only lowercase a-z, 0-9 and hyphen, with no spaces.' % component_name
     return None
 
+def targetNameValidationError(target_name):
+    if not re.match(Target_Name_Regex, target_name):
+        return 'Module name "%s" is invalid - must contain only lowercase a-z, 0-9 and hyphen, with no spaces.' % target_name
+    return None
+
 def componentNameCoerced(component_name):
     return Component_Name_Replace_With_Dash.sub('-', component_name.lower())
 
@@ -67,6 +74,18 @@ def currentDirectoryModule():
         return None
     return c
 
+def currentDirectoryTarget():
+    try:
+        t = target.Target(os.getcwd())
+    except pack.InvalidDescription as e:
+        logging.error(e)
+        return None
+    if not t:
+        logging.error(str(t.error))
+        logging.error('The current directory does not contain a valid target.')
+        return None
+    return t
+
 def currentDirectoryModuleOrTarget():
     wd = os.getcwd()
     errors = []
diff --git a/yotta/main.py b/yotta/main.py
index f1d6055..7147d01 100644
--- a/yotta/main.py
+++ b/yotta/main.py
@@ -25,7 +25,7 @@ from .lib import detect
 import yotta.lib.globalconf as globalconf
 
 # hook to support coverage information when yotta runs itself during tests:
-if 'COVERAGE_PROCESS_START' is os.environ:
+if 'COVERAGE_PROCESS_START' in os.environ:
     import coverage
     coverage.process_startup()
 
@@ -92,7 +92,7 @@ def main():
         description='Build software using re-usable components.\n'+
         'For more detailed help on each subcommand, run: yotta <subcommand> --help'
     )
-    subparser = parser.add_subparsers(metavar='<subcommand>')
+    subparser = parser.add_subparsers(dest='subcommand_name', metavar='<subcommand>')
 
     parser.add_argument('--version', nargs=0, action=FastVersionAction,
         help='display the version'
@@ -145,7 +145,8 @@ def main():
         'Search for open-source modules and targets that have been published '+
         'to the yotta registry (with yotta publish). See help for `yotta '+
         'install` for installing modules, and for `yotta target` for '+
-        'switching targets.'
+        'switching targets.',
+        'Search for published modules and targets'
     )
     addParser('init', 'init', 'Create a new module.')
     addParser('install', 'install',
@@ -164,8 +165,20 @@ def main():
         'Build the current module.'
     )
     addParser('version', 'version', 'Bump the module version, or (with no arguments) display the current version.')
-    addParser('link', 'link', 'Symlink a module.')
-    addParser('link-target', 'link_target', 'Symlink a target.')
+    addParser('link', 'link',
+        'Symlink a module to be used in another module. Use "yotta link" '+
+        '(with no arguments) to link the current module globally. Or use '+
+        '"yotta link module-name" To use a module that was previously linked '+
+        'globally in the current module.',
+        'Symlink a module'
+    )
+    addParser('link-target', 'link_target',
+        'Symlink a target to be used in another module. Use "yotta link-target" '+
+        '(with no arguments) to link the current target globally. Or use '+
+        '"yotta link-target target-name" To use a target that was previously linked '+
+        'globally in the current module.',
+        'Symlink a target'
+    )
     addParser('update', 'update', 'Update dependencies for the current module, or a specific module.')
     addParser('target', 'target', 'Set or display the target device.')
     addParser('debug', 'debug', 'Attach a debugger to the current target.  Requires target support.')
@@ -186,7 +199,12 @@ def main():
     addParser('list', 'list', 'List the dependencies of the current module, or the inherited targets of the current target.')
     addParser('outdated', 'outdated', 'Display information about dependencies which have newer versions available.')
     addParser('uninstall', 'uninstall', 'Remove a specific dependency of the current module, both from module.json and from disk.')
-    addParser('remove', 'remove', 'Remove the downloaded version of a dependency, or un-link a linked module.')
+    addParser('remove', 'remove',
+        'Remove the downloaded version of a dependency module or target, or '+
+        'un-link a linked module or target (see yotta link --help for details '+
+        'of linking). This command does not modify your module.json file.',
+        'Remove or unlink a dependency without removing it from module.json.'
+    )
     addParser('owners', 'owners', 'Add/remove/display the owners of a module or target.')
     addParser('licenses', 'licenses', 'List the licenses of the current module and its dependencies.')
     addParser('clean', 'clean', 'Remove files created by yotta and the build.')
@@ -195,16 +213,17 @@ def main():
     # short synonyms, subparser.choices is a dictionary, so use update() to
     # merge in the keys from another dictionary
     short_commands = {
-            'up':subparser.choices['update'],
-            'in':subparser.choices['install'],
-            'ln':subparser.choices['link'],
-             'v':subparser.choices['version'],
-            'ls':subparser.choices['list'],
-            'rm':subparser.choices['remove'],
-        'unlink':subparser.choices['remove'],
-         'owner':subparser.choices['owners'],
-          'lics':subparser.choices['licenses'],
-           'who':subparser.choices['whoami']
+                'up':subparser.choices['update'],
+                'in':subparser.choices['install'],
+                'ln':subparser.choices['link'],
+                 'v':subparser.choices['version'],
+                'ls':subparser.choices['list'],
+                'rm':subparser.choices['remove'],
+            'unlink':subparser.choices['remove'],
+     'unlink-target':subparser.choices['remove'],
+             'owner':subparser.choices['owners'],
+              'lics':subparser.choices['licenses'],
+               'who':subparser.choices['whoami']
     }
     subparser.choices.update(short_commands)
 
diff --git a/yotta/remove.py b/yotta/remove.py
index e734003..c961616 100644
--- a/yotta/remove.py
+++ b/yotta/remove.py
@@ -14,22 +14,58 @@ from .lib import validate
 
 
 def addOptions(parser):
-    parser.add_argument('component',
-        help='Name of the dependency to remove'
+    parser.add_argument('module', default=None, nargs='?', metavar='<module>',
+        help='Name of the module to remove. If omitted the current module '+
+             'or target will be removed from the global linking directory.'
     )
 
 def execCommand(args, following_args):
-    err = validate.componentNameValidationError(args.component)
-    if err:
-        logging.error(err)
-        return 1
-    c = validate.currentDirectoryModule()
-    if not c:
+    module_or_target = 'module'
+    if 'target' in args.subcommand_name:
+        module_or_target = 'target'
+    if args.module is not None:
+        return removeDependency(args, module_or_target)
+    else:
+        return removeGlobally(module_or_target)
+
+def rmLinkOrDirectory(path, nonexistent_warning):
+    if not os.path.exists(path):
+        logging.warning(nonexistent_warning)
         return 1
-    path = os.path.join(c.modulesPath(), args.component)
     if fsutils.isLink(path):
         fsutils.rmF(path)
     else:
         fsutils.rmRf(path)
+    return 0
+
+def removeGlobally(module_or_target):
+    # folders, , get places to install things, internal
+    from .lib import folders
+    if module_or_target == 'module':
+        global_dir = folders.globalInstallDirectory()
+        p = validate.currentDirectoryModule()
+    else:
+        global_dir = folders.globalTargetInstallDirectory()
+        p = validate.currentDirectoryTarget()
+    if p is None:
+        return 1
+    path = os.path.join(global_dir, p.getName())
+    return rmLinkOrDirectory(path, ('%s is not linked globally' % p.getName()))
+
+def removeDependency(args, module_or_target):
+    c = validate.currentDirectoryModule()
+    if not c:
+        return 1
+    if module_or_target == 'module':
+        subdir = c.modulesPath()
+        err = validate.componentNameValidationError(args.module)
+    else:
+        subdir = c.targetsPath()
+        err = validate.targetNameValidationError(args.module)
+    if err:
+        logging.error(err)
+        return 1
+    path = os.path.join(subdir, args.module)
+    return rmLinkOrDirectory(path, '%s %s not found' % (('dependency', 'target')[module_or_target=='target'], args.module))
 
 


Test patch:
diff --git a/yotta/test/cli/build.py b/yotta/test/cli/build.py
index bbd0dbb..3ff37bf 100644
--- a/yotta/test/cli/build.py
+++ b/yotta/test/cli/build.py
@@ -6,17 +6,14 @@
 
 # standard library modules, , ,
 import unittest
-import os
-import tempfile
 import subprocess
 import copy
 import re
 import datetime
 
 # internal modules:
-from yotta.lib.fsutils import mkDirP, rmRf
-from yotta.lib.detect import systemDefaultTarget
 from . import cli
+from . import util
 
 Test_Complex = {
 'module.json': '''{
@@ -87,59 +84,7 @@ int main(){
 '''
 }
 
-
-Test_Trivial_Lib = {
-'module.json':'''{
-  "name": "test-trivial-lib",
-  "version": "0.0.2",
-  "description": "Module to test trivial lib compilation",
-  "licenses": [
-    {
-      "url": "https://spdx.org/licenses/Apache-2.0",
-      "type": "Apache-2.0"
-    }
-  ],
-  "dependencies": {
-  }
-}''',
-
-'test-trivial-lib/lib.h': '''
-int foo();
-''',
-
-'source/lib.c':'''
-#include "test-trivial-lib/lib.h"
-
-int foo(){
-    return 7;
-}
-'''
-}
-
-Test_Trivial_Exe = {
-'module.json':'''{
-  "name": "test-trivial-exe",
-  "version": "0.0.2",
-  "description": "Module to test trivial exe compilation",
-  "licenses": [
-    {
-      "url": "https://spdx.org/licenses/Apache-2.0",
-      "type": "Apache-2.0"
-    }
-  ],
-  "dependencies": {
-  },
-  "bin":"./source"
-}''',
-
-'source/lib.c':'''
-int main(){
-    return 0;
-}
-'''
-}
-
-Test_Build_Info = copy.copy(Test_Trivial_Exe)
+Test_Build_Info = copy.copy(util.Test_Trivial_Exe)
 Test_Build_Info['source/lib.c'] = '''
 #include "stdio.h"
 #include YOTTA_BUILD_INFO_HEADER
@@ -202,84 +147,66 @@ int foo(){
 'test/g/a/a/b/bar.c':'#include "stdio.h"\nint bar(){ printf("bar!\\n"); return 7; }'
 }
 
-def isWindows():
-    # can't run tests that hit github without an authn token
-    return os.name == 'nt'
-
 
 class TestCLIBuild(unittest.TestCase):
-    def writeTestFiles(self, files, add_space_in_path=False):
-        test_dir = tempfile.mkdtemp()
-        if add_space_in_path:
-            test_dir = test_dir + ' spaces in path'
-
-        for path, contents in files.items():
-            path_dir, file_name =  os.path.split(path)
-            path_dir = os.path.join(test_dir, path_dir)
-            mkDirP(path_dir)
-            with open(os.path.join(path_dir, file_name), 'w') as f:
-                f.write(contents)
-        return test_dir
-
-
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_buildTrivialLib(self):
-        test_dir = self.writeTestFiles(Test_Trivial_Lib)
+        test_dir = util.writeTestFiles(util.Test_Trivial_Lib)
 
-        stdout = self.runCheckCommand(['--target', systemDefaultTarget(), 'build'], test_dir)
+        stdout = self.runCheckCommand(['--target', util.nativeTarget(), 'build'], test_dir)
 
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_buildTrivialExe(self):
-        test_dir = self.writeTestFiles(Test_Trivial_Exe)
+        test_dir = util.writeTestFiles(util.Test_Trivial_Exe)
 
-        stdout = self.runCheckCommand(['--target', systemDefaultTarget(), 'build'], test_dir)
+        stdout = self.runCheckCommand(['--target', util.nativeTarget(), 'build'], test_dir)
 
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_buildComplex(self):
-        test_dir = self.writeTestFiles(Test_Complex)
+        test_dir = util.writeTestFiles(Test_Complex)
 
-        stdout = self.runCheckCommand(['--target', systemDefaultTarget(), 'build'], test_dir)
+        stdout = self.runCheckCommand(['--target', util.nativeTarget(), 'build'], test_dir)
 
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_buildComplexSpaceInPath(self):
-        test_dir = self.writeTestFiles(Test_Complex, True)
+        test_dir = util.writeTestFiles(Test_Complex, True)
 
-        stdout = self.runCheckCommand(['--target', systemDefaultTarget(), 'build'], test_dir)
+        stdout = self.runCheckCommand(['--target', util.nativeTarget(), 'build'], test_dir)
 
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_buildTests(self):
-        test_dir = self.writeTestFiles(Test_Tests, True)
-        stdout = self.runCheckCommand(['--target', systemDefaultTarget(), 'build'], test_dir)
-        stdout = self.runCheckCommand(['--target', systemDefaultTarget(), 'test'], test_dir)
+        test_dir = util.writeTestFiles(Test_Tests, True)
+        stdout = self.runCheckCommand(['--target', util.nativeTarget(), 'build'], test_dir)
+        stdout = self.runCheckCommand(['--target', util.nativeTarget(), 'test'], test_dir)
         self.assertIn('test-a', stdout)
         self.assertIn('test-c', stdout)
         self.assertIn('test-d', stdout)
         self.assertIn('test-e', stdout)
         self.assertIn('test-f', stdout)
         self.assertIn('test-g', stdout)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_buildInfo(self):
-        test_dir = self.writeTestFiles(Test_Build_Info, True)
+        test_dir = util.writeTestFiles(Test_Build_Info, True)
         # commit all the test files to git so that the VCS build info gets
         # defined:
         subprocess.check_call(['git', 'init', '-q'], cwd=test_dir)
         subprocess.check_call(['git', 'add', '.'], cwd=test_dir)
         subprocess.check_call(['git', 'commit', '-m', 'test build info automated commit', '-q'], cwd=test_dir)
 
-        self.runCheckCommand(['--target', systemDefaultTarget(), 'build'], test_dir)
+        self.runCheckCommand(['--target', util.nativeTarget(), 'build'], test_dir)
 
         build_time = datetime.datetime.utcnow()
-        output = subprocess.check_output(['./build/' + systemDefaultTarget().split(',')[0] + '/source/test-trivial-exe'], cwd=test_dir).decode()
+        output = subprocess.check_output(['./build/' + util.nativeTarget().split(',')[0] + '/source/test-trivial-exe'], cwd=test_dir).decode()
         self.assertIn('vcs clean: 1', output)
 
         # check build timestamp
diff --git a/yotta/test/cli/cli.py b/yotta/test/cli/cli.py
index 3017aa2..541cedb 100644
--- a/yotta/test/cli/cli.py
+++ b/yotta/test/cli/cli.py
@@ -24,6 +24,10 @@ def run(arguments, cwd='.'):
          stdin = subprocess.PIPE
     )
     out, err = child.communicate()
+    # no command should ever produce a traceback:
+    if 'traceback' in (out.decode('utf-8')+err.decode('utf-8')).lower():
+        print(out+err)
+        assert(False)
     return out.decode('utf-8'), err.decode('utf-8'), child.returncode
 
 
diff --git a/yotta/test/cli/link.py b/yotta/test/cli/link.py
new file mode 100644
index 0000000..eddb5c5
--- /dev/null
+++ b/yotta/test/cli/link.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python
+# Copyright 2014-2015 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0
+# See LICENSE file for details.
+
+
+# standard library modules, , ,
+import unittest
+import os
+import tempfile
+
+# internal modules:
+from yotta.lib.folders import globalInstallDirectory
+
+from . import cli
+from . import util
+
+Test_Target = 'x86-linux-native'
+
+class TestCLILink(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.prefix_dir = tempfile.mkdtemp()
+        os.environ['YOTTA_PREFIX'] = cls.prefix_dir
+
+    @classmethod
+    def tearDownClass(cls):
+        util.rmRf(cls.prefix_dir)
+        cls.prefix_dir = None
+
+    def testLink(self):
+        linked_in_module = util.writeTestFiles(util.Test_Trivial_Lib, True)
+
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'link'], cwd=linked_in_module)
+        self.assertEqual(statuscode, 0)
+        self.assertTrue(os.path.exists(os.path.join(globalInstallDirectory(), 'test-trivial-lib')))
+
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'list'], cwd=test_module)
+        self.assertIn('missing', stdout+stderr)
+
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'link', 'test-trivial-lib'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        self.assertNotIn('broken', stdout+stderr)
+
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'list'], cwd=test_module)
+        self.assertNotIn('missing', stdout+stderr)
+
+        util.rmRf(test_module)
+        util.rmRf(linked_in_module)
+
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on this platform yet")
+    def testLinkedBuild(self):
+        linked_in_module = util.writeTestFiles(util.Test_Trivial_Lib, True)
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'link'], cwd=linked_in_module)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'link', 'test-trivial-lib'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'build'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+
+        util.rmRf(test_module)
+        util.rmRf(linked_in_module)
+
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on this platform yet")
+    def testLinkedReBuild(self):
+        # test that changing which module is linked triggers a re-build
+        linked_in_module_1 = util.writeTestFiles(util.Test_Trivial_Lib, True)
+        linked_in_module_2 = util.writeTestFiles(util.Test_Trivial_Lib, True)
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'link'], cwd=linked_in_module_1)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'link', 'test-trivial-lib'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'build'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+
+        # check that rebuild is no-op
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'build'], cwd=test_module)
+        self.assertIn('no work to do', stdout+stderr)
+        self.assertEqual(statuscode, 0)
+
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'link'], cwd=linked_in_module_2)
+        self.assertEqual(statuscode, 0)
+
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'build'], cwd=test_module)
+        self.assertNotIn('no work to do', stdout+stderr)
+        self.assertEqual(statuscode, 0)
+
+        util.rmRf(test_module)
+        util.rmRf(linked_in_module_1)
+        util.rmRf(linked_in_module_2)
+
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on this platform yet")
+    def testTargetLinkedBuild(self):
+        linked_in_target = util.writeTestFiles(util.getNativeTargetDescription(), True)
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep_Preinstalled, True)
+
+        stdout, stderr, statuscode = cli.run(['-t', 'test-native-target', '--plain', 'link-target'], cwd=linked_in_target)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', 'test-native-target', '--plain', 'link-target', 'test-native-target'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', 'test-native-target', '--plain', 'build'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+
+        util.rmRf(test_module)
+        util.rmRf(linked_in_target)
+
diff --git a/yotta/test/cli/outdated.py b/yotta/test/cli/outdated.py
index 15fbed4..be8eb4d 100644
--- a/yotta/test/cli/outdated.py
+++ b/yotta/test/cli/outdated.py
@@ -6,11 +6,9 @@
 
 # standard library modules, , ,
 import unittest
-import os
-import tempfile
 
 # internal modules:
-from yotta.lib.fsutils import mkDirP, rmRf
+from . import util
 from . import cli
 
 Test_Outdated = {
@@ -42,30 +40,17 @@ int foo(){
 }
 
 class TestCLIOutdated(unittest.TestCase):
-    def writeTestFiles(self, files, add_space_in_path=False):
-        test_dir = tempfile.mkdtemp()
-        if add_space_in_path:
-            test_dir = test_dir + ' spaces in path'
-
-        for path, contents in files.items():
-            path_dir, file_name =  os.path.split(path)
-            path_dir = os.path.join(test_dir, path_dir)
-            mkDirP(path_dir)
-            with open(os.path.join(path_dir, file_name), 'w') as f:
-                f.write(contents)
-        return test_dir
-
     def test_outdated(self):
-        path = self.writeTestFiles(Test_Outdated, True)
+        path = util.writeTestFiles(Test_Outdated, True)
 
         stdout, stderr, statuscode = cli.run(['-t', 'x86-linux-native', 'outdated'], cwd=path)
         self.assertNotEqual(statuscode, 0)
         self.assertIn('test-testing-dummy', stdout + stderr)
 
-        rmRf(path)
+        util.rmRf(path)
 
     def test_notOutdated(self):
-        path = self.writeTestFiles(Test_Outdated, True)
+        path = util.writeTestFiles(Test_Outdated, True)
 
         stdout, stderr, statuscode = cli.run(['-t', 'x86-linux-native', 'up'], cwd=path)
         self.assertEqual(statuscode, 0)
@@ -74,4 +59,4 @@ class TestCLIOutdated(unittest.TestCase):
         self.assertEqual(statuscode, 0)
         self.assertNotIn('test-testing-dummy', stdout + stderr)
 
-        rmRf(path)
+        util.rmRf(path)
diff --git a/yotta/test/cli/test.py b/yotta/test/cli/test.py
index 6a243d6..ccec431 100644
--- a/yotta/test/cli/test.py
+++ b/yotta/test/cli/test.py
@@ -6,15 +6,12 @@
 
 # standard library modules, , ,
 import unittest
-import os
-import tempfile
 import copy
 
 # internal modules:
-from yotta.lib.fsutils import mkDirP, rmRf
 from yotta.lib.detect import systemDefaultTarget
 from . import cli
-
+from . import util
 
 Test_Tests = {
 'module.json':'''{
@@ -103,26 +100,10 @@ Test_Fitler_NotFound['module.json'] = '''{
   }
 }'''
 
-def isWindows():
-    return os.name == 'nt'
-
 class TestCLITest(unittest.TestCase):
-    def writeTestFiles(self, files, add_space_in_path=False):
-        test_dir = tempfile.mkdtemp()
-        if add_space_in_path:
-            test_dir = test_dir + ' spaces in path'
-
-        for path, contents in files.items():
-            path_dir, file_name =  os.path.split(path)
-            path_dir = os.path.join(test_dir, path_dir)
-            mkDirP(path_dir)
-            with open(os.path.join(path_dir, file_name), 'w') as f:
-                f.write(contents)
-        return test_dir
-
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_tests(self):
-        test_dir = self.writeTestFiles(Test_Tests, True)
+        test_dir = util.writeTestFiles(Test_Tests, True)
         output = self.runCheckCommand(['--target', systemDefaultTarget(), 'build'], test_dir)
         output = self.runCheckCommand(['--target', systemDefaultTarget(), 'test'], test_dir)
         self.assertIn('test-a passed', output)
@@ -131,17 +112,17 @@ class TestCLITest(unittest.TestCase):
         self.assertIn('test-e passed', output)
         self.assertIn('test-f passed', output)
         self.assertIn('test-g passed', output)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_testOutputFilterPassing(self):
-        test_dir = self.writeTestFiles(Test_Fitler_Pass, True)
+        test_dir = util.writeTestFiles(Test_Fitler_Pass, True)
         stdout = self.runCheckCommand(['--target', systemDefaultTarget(), 'test'], test_dir)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_testOutputFilterFailing(self):
-        test_dir = self.writeTestFiles(Test_Fitler_Fail, True)
+        test_dir = util.writeTestFiles(Test_Fitler_Fail, True)
         stdout, stderr, statuscode = cli.run(['--target', systemDefaultTarget(), 'test'], cwd=test_dir)
         if statuscode == 0:
             print(stdout)
@@ -153,17 +134,17 @@ class TestCLITest(unittest.TestCase):
         self.assertIn('test-f failed', '%s %s' % (stdout, stderr))
         self.assertIn('test-g failed', '%s %s' % (stdout, stderr))
         self.assertNotEqual(statuscode, 0)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
-    @unittest.skipIf(isWindows(), "can't build natively on windows yet")
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on windows yet")
     def test_testOutputFilterNotFound(self):
-        test_dir = self.writeTestFiles(Test_Fitler_NotFound, True)
+        test_dir = util.writeTestFiles(Test_Fitler_NotFound, True)
         stdout, stderr, statuscode = cli.run(['--target', systemDefaultTarget(), 'test'], cwd=test_dir)
         if statuscode == 0:
             print(stdout)
             print(stderr)
         self.assertNotEqual(statuscode, 0)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
     def runCheckCommand(self, args, test_dir):
         stdout, stderr, statuscode = cli.run(args, cwd=test_dir)
diff --git a/yotta/test/cli/unlink.py b/yotta/test/cli/unlink.py
new file mode 100644
index 0000000..ff6eda6
--- /dev/null
+++ b/yotta/test/cli/unlink.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python
+# Copyright 2014-2015 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0
+# See LICENSE file for details.
+
+
+# standard library modules, , ,
+import unittest
+import tempfile
+import os
+
+# internal modules:
+from . import cli
+from . import util
+
+Test_Target = 'x86-linux-native'
+
+class TestCLIUnLink(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.prefix_dir = tempfile.mkdtemp()
+        os.environ['YOTTA_PREFIX'] = cls.prefix_dir
+
+    @classmethod
+    def tearDownClass(cls):
+        util.rmRf(cls.prefix_dir)
+        cls.prefix_dir = None
+
+    def testUnlinkNonexistentModule(self):
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'unlink', 'doesnotexist'], cwd=test_module)
+        self.assertNotEqual(statuscode, 0)
+        util.rmRf(test_module)
+
+    def testUnlinkNonexistentTarget(self):
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'unlink-target', 'doesnotexist'], cwd=test_module)
+        self.assertNotEqual(statuscode, 0)
+        util.rmRf(test_module)
+
+    def testUnlinkNotLinkedModuleGlobally(self):
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'unlink'], cwd=test_module)
+        self.assertNotEqual(statuscode, 0)
+        util.rmRf(test_module)
+
+    def testUnlinkNotLinkedTargetGlobally(self):
+        test_target = util.writeTestFiles(util.getNativeTargetDescription(), True)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'unlink'], cwd=test_target)
+        self.assertNotEqual(statuscode, 0)
+        util.rmRf(test_target)
+
+    def testUnlinkModuleGlobally(self):
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'link'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'unlink'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        util.rmRf(test_module)
+
+    def testUnlinkTargetGlobally(self):
+        test_target = util.writeTestFiles(util.getNativeTargetDescription(), True)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'link-target'], cwd=test_target)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', Test_Target, '--plain', 'unlink-target'], cwd=test_target)
+        self.assertEqual(statuscode, 0)
+        util.rmRf(test_target)
+
+    def testUnlinkModule(self):
+        linked_in_module = util.writeTestFiles(util.Test_Trivial_Lib, True)
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep, True)
+
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'link'], cwd=linked_in_module)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'link', 'test-trivial-lib'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        self.assertTrue(os.path.exists(os.path.join(test_module, 'yotta_modules', 'test-trivial-lib')))
+        stdout, stderr, statuscode = cli.run(['-t', util.nativeTarget(), '--plain', 'unlink', 'test-trivial-lib'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        self.assertTrue(not os.path.exists(os.path.join(test_module, 'yotta_modules', 'test-trivial-lib')))
+
+        util.rmRf(test_module)
+        util.rmRf(linked_in_module)
+
+    @unittest.skipIf(not util.canBuildNatively(), "can't build natively on this platform yet")
+    def testUnlinkTarget(self):
+        linked_in_target = util.writeTestFiles(util.getNativeTargetDescription(), True)
+        test_module = util.writeTestFiles(util.Test_Testing_Trivial_Lib_Dep_Preinstalled, True)
+
+        stdout, stderr, statuscode = cli.run(['-t', 'test-native-target', '--plain', 'link-target'], cwd=linked_in_target)
+        self.assertEqual(statuscode, 0)
+        stdout, stderr, statuscode = cli.run(['-t', 'test-native-target', '--plain', 'link-target', 'test-native-target'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        self.assertTrue(os.path.exists(os.path.join(test_module, 'yotta_targets', 'test-native-target')))
+        stdout, stderr, statuscode = cli.run(['-t', 'test-native-target', '--plain', 'unlink-target', 'test-native-target'], cwd=test_module)
+        self.assertEqual(statuscode, 0)
+        self.assertTrue(not os.path.exists(os.path.join(test_module, 'yotta_targets', 'test-native-target')))
+
+        util.rmRf(test_module)
+        util.rmRf(linked_in_target)
+
+
diff --git a/yotta/test/cli/update.py b/yotta/test/cli/update.py
index 4906fab..8581689 100644
--- a/yotta/test/cli/update.py
+++ b/yotta/test/cli/update.py
@@ -6,12 +6,10 @@
 
 # standard library modules, , ,
 import unittest
-import os
-import tempfile
 
 # internal modules:
-from yotta.lib.fsutils import mkDirP, rmRf
 from . import cli
+from . import util
 
 Test_Outdated = {
 'module.json':'''{
@@ -42,39 +40,26 @@ int foo(){
 }
 
 class TestCLIUpdate(unittest.TestCase):
-    def writeTestFiles(self, files, add_space_in_path=False):
-        test_dir = tempfile.mkdtemp()
-        if add_space_in_path:
-            test_dir = test_dir + ' spaces in path'
-
-        for path, contents in files.items():
-            path_dir, file_name =  os.path.split(path)
-            path_dir = os.path.join(test_dir, path_dir)
-            mkDirP(path_dir)
-            with open(os.path.join(path_dir, file_name), 'w') as f:
-                f.write(contents)
-        return test_dir
-
     def test_update(self):
-        path = self.writeTestFiles(Test_Outdated, True)
+        path = util.writeTestFiles(Test_Outdated, True)
 
         stdout, stderr, statuscode = cli.run(['-t', 'x86-linux-native', 'update'], cwd=path)
         self.assertEqual(statuscode, 0)
         self.assertIn('download test-testing-dummy', stdout + stderr)
 
-        rmRf(path)
+        util.rmRf(path)
 
     def test_updateExplicit(self):
-        path = self.writeTestFiles(Test_Outdated, True)
+        path = util.writeTestFiles(Test_Outdated, True)
 
         stdout, stderr, statuscode = cli.run(['-t', 'x86-linux-native', 'update', 'test-testing-dummy'], cwd=path)
         self.assertEqual(statuscode, 0)
         self.assertIn('download test-testing-dummy', stdout + stderr)
 
-        rmRf(path)
+        util.rmRf(path)
 
     def test_updateNothing(self):
-        path = self.writeTestFiles(Test_Outdated, True)
+        path = util.writeTestFiles(Test_Outdated, True)
 
         stdout, stderr, statuscode = cli.run(['-t', 'x86-linux-native', 'up'], cwd=path)
         self.assertEqual(statuscode, 0)
@@ -84,4 +69,4 @@ class TestCLIUpdate(unittest.TestCase):
         self.assertEqual(statuscode, 0)
         self.assertNotIn('download test-testing-dummy', stdout + stderr)
 
-        rmRf(path)
+        util.rmRf(path)
diff --git a/yotta/test/cli/util.py b/yotta/test/cli/util.py
new file mode 100644
index 0000000..553619b
--- /dev/null
+++ b/yotta/test/cli/util.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python
+# Copyright 2015 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0
+# See LICENSE file for details.
+
+# standard library modules, , ,
+import tempfile
+import os
+import copy
+
+# internal modules:
+import yotta.lib.fsutils as fsutils
+from yotta.lib.detect import systemDefaultTarget
+
+# some simple example module definitions that can be re-used by multiple tests:
+Test_Trivial_Lib = {
+'module.json':'''{
+  "name": "test-trivial-lib",
+  "version": "1.0.0",
+  "description": "Module to test trivial lib compilation",
+  "license": "Apache-2.0",
+  "dependencies": {
+  }
+}''',
+
+'test-trivial-lib/lib.h': '''
+int foo();
+''',
+
+'source/lib.c':'''
+#include "test-trivial-lib/lib.h"
+int foo(){ return 7; }
+'''
+}
+
+Test_Trivial_Exe = {
+'module.json':'''{
+  "name": "test-trivial-exe",
+  "version": "1.0.0",
+  "description": "Module to test trivial exe compilation",
+  "license": "Apache-2.0",
+  "dependencies": {
+  },
+  "bin":"./source"
+}''',
+
+'source/lib.c':'''
+int main(){ return 0; }
+'''
+}
+
+Test_Testing_Trivial_Lib_Dep = {
+'module.json':'''{
+  "name": "test-simple-module",
+  "version": "1.0.0",
+  "description": "a simple test module",
+  "author": "Someone Somewhere <someone.somewhere@yottabuild.org>",
+  "license": "Apache-2.0",
+  "dependencies": {
+    "test-trivial-lib": "^1.0.0"
+  }
+}
+''',
+
+'test-simple-module/simple.h': '''
+int simple();
+''',
+
+'source/lib.c':'''
+#include "test-simple-module/simple.h"
+int simple(){ return 123; }
+'''
+}
+
+Test_Testing_Trivial_Lib_Dep_Preinstalled = copy.copy(Test_Testing_Trivial_Lib_Dep)
+for k, v in Test_Trivial_Lib.items():
+    Test_Testing_Trivial_Lib_Dep_Preinstalled['yotta_modules/test-trivial-lib/' + k] = v
+
+
+def getNativeTargetDescription():
+    # actually returns a trivial target which inherits from the native target
+    native_target = nativeTarget()
+    if ',' in native_target:
+        native_target = native_target[:native_target.find(',')]
+    return {
+    'target.json':'''{
+      "name": "test-native-target",
+      "version": "1.0.0",
+      "license": "Apache-2.0",
+      "inherits": {
+        "%s": "*"
+      }
+    }
+    ''' % native_target
+    }
+
+
+def writeTestFiles(files, add_space_in_path=False):
+    ''' write a dictionary of filename:contents into a new temporary directory
+    '''
+    test_dir = tempfile.mkdtemp()
+    if add_space_in_path:
+        test_dir = test_dir + ' spaces in path'
+
+    for path, contents in files.items():
+        path_dir, file_name =  os.path.split(path)
+        path_dir = os.path.join(test_dir, path_dir)
+        fsutils.mkDirP(path_dir)
+        with open(os.path.join(path_dir, file_name), 'w') as f:
+            f.write(contents)
+    return test_dir
+
+def isWindows():
+    # can't run tests that hit github without an authn token
+    return os.name == 'nt'
+
+def canBuildNatively():
+    return not isWindows()
+
+def nativeTarget():
+    assert(canBuildNatively())
+    return systemDefaultTarget()
+
+#expose rmRf for convenience
+rmRf = fsutils.rmRf
diff --git a/yotta/test/config.py b/yotta/test/config.py
index 8c7b417..8c67192 100644
--- a/yotta/test/config.py
+++ b/yotta/test/config.py
@@ -7,12 +7,11 @@
 import unittest
 import copy
 import os
-import tempfile
 import logging
 
 # internal modules:
-from yotta.lib.fsutils import mkDirP, rmRf
 from yotta.lib import validate
+from .cli import util
 
 logging.basicConfig(
     level=logging.ERROR
@@ -78,19 +77,6 @@ Test_Module_Config_Ignored['module.json'] = '''{
 }'''
 
 class ConfigTest(unittest.TestCase):
-    def writeTestFiles(self, files, add_space_in_path=False):
-        test_dir = tempfile.mkdtemp()
-        if add_space_in_path:
-            test_dir = test_dir + ' spaces in path'
-
-        for path, contents in files.items():
-            path_dir, file_name =  os.path.split(path)
-            path_dir = os.path.join(test_dir, path_dir)
-            mkDirP(path_dir)
-            with open(os.path.join(path_dir, file_name), 'w') as f:
-                f.write(contents)
-        return test_dir
-
     def setUp(self):
         self.restore_cwd = os.getcwd()
 
@@ -98,7 +84,7 @@ class ConfigTest(unittest.TestCase):
         os.chdir(self.restore_cwd)
 
     def test_targetConfigMerge(self):
-        test_dir = self.writeTestFiles(Test_Target_Config_Merge, True)
+        test_dir = util.writeTestFiles(Test_Target_Config_Merge, True)
 
         os.chdir(test_dir)
         c = validate.currentDirectoryModule()
@@ -118,10 +104,10 @@ class ConfigTest(unittest.TestCase):
         self.assertEqual(merged_config['bar']['d'], "def")
 
         os.chdir(self.restore_cwd)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
     def test_targetAppConfigMerge(self):
-        test_dir = self.writeTestFiles(Test_Target_Config_Merge_App, True)
+        test_dir = util.writeTestFiles(Test_Target_Config_Merge_App, True)
 
         os.chdir(test_dir)
         c = validate.currentDirectoryModule()
@@ -144,10 +130,10 @@ class ConfigTest(unittest.TestCase):
         self.assertEqual(merged_config['new'], 123)
 
         os.chdir(self.restore_cwd)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
     def test_moduleConfigIgnored(self):
-        test_dir = self.writeTestFiles(Test_Module_Config_Ignored, True)
+        test_dir = util.writeTestFiles(Test_Module_Config_Ignored, True)
 
         os.chdir(test_dir)
         c = validate.currentDirectoryModule()
@@ -157,5 +143,5 @@ class ConfigTest(unittest.TestCase):
         self.assertNotIn("new", merged_config)
 
         os.chdir(self.restore_cwd)
-        rmRf(test_dir)
+        util.rmRf(test_dir)
 
diff --git a/yotta/test/ignores.py b/yotta/test/ignores.py
index 3a5f8e9..16832a8 100644
--- a/yotta/test/ignores.py
+++ b/yotta/test/ignores.py
@@ -8,13 +8,12 @@
 # standard library modules, , ,
 import unittest
 import os
-import tempfile
 
 # internal modules:
-from yotta.lib.fsutils import mkDirP, rmRf
 from yotta.lib.detect import systemDefaultTarget
 from yotta.lib import component
 from .cli import cli
+from .cli import util
 
 Test_Files = {
     '.yotta_ignore': '''
@@ -115,24 +114,14 @@ def isWindows():
     # can't run tests that hit github without an authn token
     return os.name == 'nt'
 
-def writeTestFiles(files):
-    test_dir = tempfile.mkdtemp()
-    for path, contents in files.items():
-        path_dir, file_name =  os.path.split(path)
-        path_dir = os.path.join(test_dir, path_dir)
-        mkDirP(path_dir)
-        with open(os.path.join(path_dir, file_name), 'w') as f:
-            f.write(contents)
-    return test_dir
-
 class TestPackIgnores(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
-        cls.test_dir = writeTestFiles(Test_Files)
+        cls.test_dir = util.writeTestFiles(Test_Files)
 
     @classmethod
     def tearDownClass(cls):
-        rmRf(cls.test_dir)
+        util.rmRf(cls.test_dir)
 
     def test_absolute_ignores(self):
         c = component.Component(self.test_dir)
@@ -158,7 +147,7 @@ class TestPackIgnores(unittest.TestCase):
         self.assertTrue(c.ignores('test/someothertest/alsoignored.c'))
 
     def test_default_ignores(self):
-        default_test_dir = writeTestFiles(Default_Test_Files)
+        default_test_dir = util.writeTestFiles(Default_Test_Files)
         c = component.Component(default_test_dir)
         self.assertTrue(c.ignores('.something.c.swp'))
         self.assertTrue(c.ignores('.something.c~'))
@@ -173,7 +162,7 @@ class TestPackIgnores(unittest.TestCase):
         self.assertTrue(c.ignores('build'))
         self.assertTrue(c.ignores('.yotta.json'))
 
-        rmRf(default_test_dir)
+        util.rmRf(default_test_dir)
 
     def test_comments(self):
         c = component.Component(self.test_dir)