Repository: iterative/dvc

Issue:
Add 'dvc mv'
We have 'add' and 'remove', but lack 'mv'.

Discussion:

Patch:
diff --git a/dvc/cli.py b/dvc/cli.py
index 5f406ac36..27dd57050 100644
--- a/dvc/cli.py
+++ b/dvc/cli.py
@@ -8,6 +8,7 @@ from multiprocessing import cpu_count
 from dvc.logger import Logger
 from dvc.command.init import CmdInit
 from dvc.command.remove import CmdRemove
+from dvc.command.move import CmdMove
 from dvc.command.run import CmdRun
 from dvc.command.repro import CmdRepro
 from dvc.command.data_sync import CmdDataPush, CmdDataPull, CmdDataFetch
@@ -289,6 +290,19 @@ def parse_args(argv=None):
                         help='DVC files.')
     remove_parser.set_defaults(func=CmdRemove)
 
+    # Move
+    move_parser = subparsers.add_parser(
+                        'move',
+                        parents=[parent_parser],
+                        help='Move output of DVC file.')
+    move_parser.add_argument(
+                        'src',
+                        help='Source')
+    move_parser.add_argument(
+                        'dst',
+                        help='Destination')
+    move_parser.set_defaults(func=CmdMove)
+
     # Garbage collector
     gc_parser = subparsers.add_parser(
                         'gc',
diff --git a/dvc/command/move.py b/dvc/command/move.py
new file mode 100644
index 000000000..bef31165b
--- /dev/null
+++ b/dvc/command/move.py
@@ -0,0 +1,13 @@
+from dvc.exceptions import DvcException
+from dvc.command.common.base import CmdBase
+
+
+class CmdMove(CmdBase):
+    def run(self):
+        try:
+            self.project.move(self.args.src, self.args.dst)
+        except DvcException as ex:
+            self.project.logger.error('Failed to move \'{}\' -> \'{}\''.format(self.args.src,
+                                                                               self.args.dst), ex)
+            return 1
+        return 0
diff --git a/dvc/dependency/local.py b/dvc/dependency/local.py
index 0d7532107..47a903929 100644
--- a/dvc/dependency/local.py
+++ b/dvc/dependency/local.py
@@ -33,7 +33,7 @@ class DependencyLOCAL(DependencyBase):
         if not os.path.isabs(path):
             path = self.ospath(path)
             path = os.path.join(stage.cwd, path)
-        self.path = os.path.normpath(path)
+        self.path = os.path.abspath(os.path.normpath(path))
 
         self.path_info = {'scheme': 'local',
                           'path': self.path}
diff --git a/dvc/output/local.py b/dvc/output/local.py
index b482e350e..703dee528 100644
--- a/dvc/output/local.py
+++ b/dvc/output/local.py
@@ -82,3 +82,15 @@ class OutputLOCAL(DependencyLOCAL):
         self.remote.remove(self.path_info)
         if ignore_remove and self.use_cache:
             self.project.scm.ignore_remove(self.path)
+
+    def move(self, out):
+        if self.use_cache:
+            self.project.scm.ignore_remove(self.path)
+
+        self.remote.move(self.path_info, out.path_info)
+        self.path = out.path
+        self.path_info = out.path_info
+        self.save()
+
+        if self.use_cache:
+            self.project.scm.ignore(self.path)
diff --git a/dvc/project.py b/dvc/project.py
index 370306421..582f3e7af 100644
--- a/dvc/project.py
+++ b/dvc/project.py
@@ -5,6 +5,7 @@ import json
 import networkx as nx
 from jsonpath_rw import parse
 
+import dvc.output as Output
 from dvc.logger import Logger
 from dvc.exceptions import DvcException
 from dvc.stage import Stage
@@ -137,6 +138,35 @@ class Project(object):
 
         return stage
 
+    def move(self, from_path, to_path):
+        from_out = Output.loads_from(Stage(self, cwd=os.curdir), [from_path])[0]
+
+        found = False
+        for stage in self.stages():
+            for out in stage.outs:
+                if out.path != from_out.path:
+                    continue
+
+                if not stage.is_data_source:
+                    raise DvcException('Dvcfile \'{}\' is not a data source.'.format(stage.rel_path))
+
+                found = True
+                to_out = Output.loads_from(out.stage, [to_path], out.cache, out.metric)[0]
+                out.move(to_out)
+
+                stage_base = os.path.basename(stage.path).rstrip(Stage.STAGE_FILE_SUFFIX)
+                stage_dir = os.path.dirname(stage.path)
+                from_base = os.path.basename(from_path)
+                to_base = os.path.basename(to_path)
+                if stage_base == from_base:
+                    os.unlink(stage.path)
+                    stage.path = os.path.join(stage_dir, to_base + Stage.STAGE_FILE_SUFFIX)
+
+            stage.dump()
+
+        if not found:
+            raise DvcException('Unable to find dvcfile with output \'{}\''.format(from_path))
+
     def run(self,
             cmd=None,
             deps=[],
diff --git a/dvc/remote/base.py b/dvc/remote/base.py
index 11e6fbd43..6fb0a791f 100644
--- a/dvc/remote/base.py
+++ b/dvc/remote/base.py
@@ -79,6 +79,12 @@ class RemoteBase(object):
     def upload(self, path, path_info, name=None):
         raise NotImplementedError
 
+    def remove(self, path_info):
+        raise NotImplementedError
+
+    def move(self, path_info):
+        raise NotImplementedError
+
     def cache_file_key(self, fname):
         """ Key of a file within the bucket """
         relpath = os.path.relpath(fname, self.project.cache.local.cache_dir)
diff --git a/dvc/remote/local.py b/dvc/remote/local.py
index ff7b97b1b..7b887209f 100644
--- a/dvc/remote/local.py
+++ b/dvc/remote/local.py
@@ -246,6 +246,12 @@ class RemoteLOCAL(RemoteBase):
 
         remove(path_info['path'])
 
+    def move(self, from_info, to_info):
+        if from_info['scheme'] != 'local' or to_info['scheme'] != 'local':
+            raise NotImplementedError
+
+        move(from_info['path'], to_info['path'])
+
     def cache_file_key(self, path):
         relpath = os.path.relpath(os.path.abspath(path), self.project.cache.local.cache_dir)
         return os.path.join(self.prefix, relpath)


Test patch:
diff --git a/tests/test_move.py b/tests/test_move.py
new file mode 100644
index 000000000..9b81b7bb6
--- /dev/null
+++ b/tests/test_move.py
@@ -0,0 +1,41 @@
+import os
+
+from dvc.main import main
+from dvc.exceptions import DvcException
+
+from tests.basic_env import TestDvc
+
+
+class TestMove(TestDvc):
+    def test(self):
+        dst = self.FOO + '1'
+        self.dvc.add(self.FOO)
+        self.dvc.move(self.FOO, dst)
+
+        self.assertFalse(os.path.isfile(self.FOO))
+        self.assertTrue(os.path.isfile(dst))
+
+
+class TestMoveNonExistentFile(TestDvc):
+    def test(self):
+        with self.assertRaises(DvcException) as cx:
+            self.dvc.move('non_existent_file', 'dst')
+
+
+class TestMoveDirectory(TestDvc):
+    def test(self):
+        dst = 'dst'
+        stage_add = self.dvc.add(self.DATA_DIR)
+        self.dvc.move(self.DATA_DIR, dst)
+        self.assertFalse(os.path.exists(self.DATA_DIR))
+        self.assertTrue(os.path.exists(dst))
+
+
+class TestCmdMove(TestDvc):
+    def test(self):
+        stage = self.dvc.add(self.FOO)
+        ret = main(['move', self.FOO, self.FOO + '1'])
+        self.assertEqual(ret, 0)
+
+        ret = main(['move', 'non-existing-file', 'dst'])
+        self.assertNotEqual(ret, 0)