Repository: iterative/dvc

Issue:
dvc: output overlay and cycle not detected
https://discordapp.com/channels/485586884165107732/485586884165107734/558304594371805194




Discussion:

Resolving patch:
diff --git a/dvc/exceptions.py b/dvc/exceptions.py
--- a/dvc/exceptions.py
+++ b/dvc/exceptions.py
@@ -227,3 +227,17 @@ def __init__(self):
         super(RecursiveAddingWhileUsingFilename, self).__init__(
             "using fname with recursive is not allowed."
         )
+
+
+class OverlappingOutputPathsError(DvcException):
+    def __init__(self, out_1, out_2):
+        super(OverlappingOutputPathsError, self).__init__(
+            "Paths for outs:\n'{}'('{}')\n'{}'('{}')\noverlap. To avoid "
+            "unpredictable behaviour, rerun command with non overlapping outs "
+            "paths.".format(
+                str(out_1),
+                out_1.stage.relpath,
+                str(out_2),
+                out_2.stage.relpath,
+            )
+        )
diff --git a/dvc/repo/__init__.py b/dvc/repo/__init__.py
--- a/dvc/repo/__init__.py
+++ b/dvc/repo/__init__.py
@@ -283,6 +283,7 @@ def graph(self, stages=None, from_directory=None):
         from dvc.exceptions import (
             OutputDuplicationError,
             StagePathAsOutputError,
+            OverlappingOutputPathsError,
         )
 
         G = nx.DiGraph()
@@ -293,7 +294,15 @@ def graph(self, stages=None, from_directory=None):
 
         for stage in stages:
             for out in stage.outs:
-                existing = [o.stage for o in outs if o.path == out.path]
+                existing = []
+                for o in outs:
+                    if o.path == out.path:
+                        existing.append(o.stage)
+
+                    in_o_dir = out.path.startswith(o.path + o.sep)
+                    in_out_dir = o.path.startswith(out.path + out.sep)
+                    if in_o_dir or in_out_dir:
+                        raise OverlappingOutputPathsError(o, out)
 
                 if existing:
                     stages = [stage.relpath, existing[0].relpath]


Test patch:
diff --git a/tests/test_repro.py b/tests/test_repro.py
--- a/tests/test_repro.py
+++ b/tests/test_repro.py
@@ -115,9 +115,10 @@ def test(self):
         faulty_stage_path = os.path.join("dir2", "something.dvc")
 
         with open(faulty_stage_path, "w") as fd:
+            output = os.path.join("..", "something")
             stage_dump = {
-                "cmd": "echo something > something",
-                "outs": [{"path": "something"}],
+                "cmd": "echo something > {}".format(output),
+                "outs": [{"path": output}],
             }
 
             yaml.safe_dump(stage_dump, fd, default_flow_style=False)
@@ -156,9 +157,10 @@ def test_nested(self):
         error_stage_path = os.path.join(nested_dir, "dir", "error.dvc")
 
         with open(error_stage_path, "w") as fd:
+            output = os.path.join("..", "..", "something")
             stage_dump = {
-                "cmd": "echo something > something",
-                "outs": [{"path": "something"}],
+                "cmd": "echo something > {}".format(output),
+                "outs": [{"path": output}],
             }
 
             yaml.safe_dump(stage_dump, fd, default_flow_style=False)
diff --git a/tests/test_run.py b/tests/test_run.py
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -20,6 +20,7 @@
     CyclicGraphError,
     ArgumentDuplicationError,
     StagePathAsOutputError,
+    OverlappingOutputPathsError,
 )
 
 from tests.basic_env import TestDvc
@@ -198,12 +199,7 @@ def test_cwd(self):
         self.dvc.run(cmd="", deps=[], outs=[self.DATA_DIR])
 
         with self.assertRaises(StagePathAsOutputError):
-            self.dvc.run(
-                cmd="",
-                cwd=self.DATA_DIR,
-                outs=[self.FOO],
-                fname="inside-cwd.dvc",
-            )
+            self.dvc.run(cmd="", cwd=self.DATA_DIR, fname="inside-cwd.dvc")
 
     def test_file_name(self):
         self.dvc.run(cmd="", deps=[], outs=[self.DATA_DIR])
@@ -820,3 +816,32 @@ def outs_command(self):
 
     def test(self):
         self._test()
+
+
+class TestShouldRaiseOnOverlappingOutputPaths(TestDvc):
+    def test(self):
+        ret = main(["add", self.DATA_DIR])
+        self.assertEqual(0, ret)
+
+        with self.assertRaises(OverlappingOutputPathsError) as err:
+            self.dvc.run(
+                outs=[self.DATA], cmd="echo data >> {}".format(self.DATA)
+            )
+        error_output = str(err.exception)
+
+        data_dir_stage = self.DATA_DIR + Stage.STAGE_FILE_SUFFIX
+        data_stage = os.path.basename(self.DATA) + Stage.STAGE_FILE_SUFFIX
+
+        self.assertIn("Paths for outs:\n", error_output)
+        self.assertIn(
+            "\n'{}'('{}')\n".format(self.DATA_DIR, data_dir_stage),
+            error_output,
+        )
+        self.assertIn(
+            "\n'{}'('{}')\n".format(self.DATA, data_stage), error_output
+        )
+        self.assertIn(
+            "\noverlap. To avoid unpredictable behaviour, rerun "
+            "command with non overlapping outs paths.",
+            error_output,
+        )