Repository: iterative/dvc

Issue:
move: creates inconsistency when moving tracked file into tracked folder 
# Bug Report

## Description
Assume that you track the whole folder `dir1` with `dir1.dvc`. You cannot add an extra file `dir1/a.txt` and track with `dir1/a.txt.dvc` (with the command `dvc add a.txt` you get an error). However, you can first create a file `b.txt` outside `dir1` and move it with the .dvc-file into  `dir1` with `dvc move`.

### Reproduce

```bash
dvc init
mkdir dir1
dvc add dir1

# Error as excected
touch dir1/a.txt
dvc add dir1/a.txt

# No error (unexpected)
touch b.txt
dvc add b.txt
dvc move b.txt
dvc move b.txt dir1

# Result
ls dir1
## a.txt  b.txt  b.txt.dvc

# After a commit we get
dvc commit
cat dir1.dvc 
#outs:
#- md5: c53e75a492b0e8ccefef15fbf24d4c22.dir
#  size: 70
#  nfiles: 3
#  path: dir1
```

### Expected
I excpeted one of the two cases:
1. One should not be able to move a tracked file into a tracked folder and get an error.
2. Only the `b.txt` should be moved to the folder `dir1`. `b.txt.dvc` should be removed (or modified if it tracks other files).

### Environment information

Sorry, dvc doctor hangs on my system. Produced on Linux with ext4.


Discussion:
@mistermult , thanks.

> Sorry, dvc doctor hangs on my system. Produced on Linux with ext4.
 
Strange, but I had tested on the latest version, it can be reproduced. In my opinion, it is a bug.

Resolving patch:
diff --git a/dvc/repo/move.py b/dvc/repo/move.py
--- a/dvc/repo/move.py
+++ b/dvc/repo/move.py
@@ -53,18 +53,22 @@ def move(self, from_path, to_path):
     stage_name = os.path.splitext(os.path.basename(stage.path))[0]
     from_name = os.path.basename(from_out.fspath)
     if stage_name == from_name:
-        os.unlink(stage.path)
-
-        stage.path = os.path.join(
+        new_fname = os.path.join(
             os.path.dirname(to_path),
             os.path.basename(to_path) + DVC_FILE_SUFFIX,
         )
-
-        stage.wdir = os.path.abspath(
+        new_wdir = os.path.abspath(
             os.path.join(os.curdir, os.path.dirname(to_path))
         )
+        to_path = os.path.relpath(to_path, new_wdir)
+        new_stage = self.stage.create(
+            single_stage=True, fname=new_fname, wdir=new_wdir, outs=[to_path],
+        )
 
-    to_path = os.path.relpath(to_path, stage.wdir)
+        os.unlink(stage.path)
+        stage = new_stage
+    else:
+        to_path = os.path.relpath(to_path, stage.wdir)
 
     to_out = Output.loads_from(stage, [to_path], out.use_cache, out.metric)[0]
 
diff --git a/dvc/stage/utils.py b/dvc/stage/utils.py
--- a/dvc/stage/utils.py
+++ b/dvc/stage/utils.py
@@ -330,7 +330,7 @@ def validate_state(
 def validate_kwargs(single_stage: bool = False, fname: str = None, **kwargs):
     """Prepare, validate and process kwargs passed from cli"""
     cmd = kwargs.get("cmd")
-    if not cmd:
+    if not cmd and not single_stage:
         raise InvalidArgumentError("command is not specified")
 
     stage_name = kwargs.get("name")


Test patch:
diff --git a/tests/func/test_move.py b/tests/func/test_move.py
--- a/tests/func/test_move.py
+++ b/tests/func/test_move.py
@@ -1,5 +1,7 @@
 import os
 
+import pytest
+
 from dvc.dvcfile import DVC_FILE_SUFFIX
 from dvc.exceptions import DvcException, MoveNotDataSourceError
 from dvc.main import main
@@ -211,3 +213,34 @@ def test_should_move_to_dir_on_non_default_stage_file(tmp_dir, dvc):
     dvc.move("file", "directory")
 
     assert os.path.exists(os.path.join("directory", "file"))
+
+
+def test_move_gitignored(tmp_dir, scm, dvc):
+    from dvc.dvcfile import FileIsGitIgnored
+
+    tmp_dir.dvc_gen({"foo": "foo"})
+
+    os.mkdir("dir")
+    (tmp_dir / "dir").gen(".gitignore", "*")
+
+    with pytest.raises(FileIsGitIgnored):
+        dvc.move("foo", "dir")
+
+    assert (tmp_dir / "foo").read_text() == "foo"
+    assert (tmp_dir / "foo.dvc").exists()
+    assert not (tmp_dir / "dir" / "foo").exists()
+    assert not (tmp_dir / "dir" / "foo.dvc").exists()
+
+
+def test_move_output_overlap(tmp_dir, dvc):
+    from dvc.exceptions import OverlappingOutputPathsError
+
+    tmp_dir.dvc_gen({"foo": "foo", "dir": {"bar": "bar"}})
+
+    with pytest.raises(OverlappingOutputPathsError):
+        dvc.move("foo", "dir")
+
+    assert (tmp_dir / "foo").read_text() == "foo"
+    assert (tmp_dir / "foo.dvc").exists()
+    assert not (tmp_dir / "dir" / "foo").exists()
+    assert not (tmp_dir / "dir" / "foo.dvc").exists()