Repository: iterative/dvc

Issue:
run: check that dvc file is not placed inside output directory



Discussion:
Why is this a bug?

Is this the correct way to reproduce it?
```
#!/usr/bin/env bash

rm -rf example
mkdir example
cd example

git init
dvc init

mkdir -p data
dvc run --cwd data -o foo 'echo foo > foo'
```
This is a bug, but a correct way to reproduce it would be:
```
#!/bin/bash

set -e
set -x

rm -rf myrepo
mkdir myrepo
cd myrepo
git init
dvc init

git commit -m init

dvc run -o dir 'mkdir dir && echo foo > dir/foo'
dvc run -c dir -o ../bar 'echo bar > ../bar'

dvc status
```
there is no bar.dvc in the last `dvc status`, because dvc doesn't see it, since it skips scanning output directories of other stages. So if you would to run `dvc repro dir.dvc`, it would wipe out `dir`, destroying `bar.dvc`. So we need to prevent this in the first place, by not allowing to place .dvc files in directories that are already specified as outputs in other stages.

Resolving patch:
diff --git a/dvc/exceptions.py b/dvc/exceptions.py
--- a/dvc/exceptions.py
+++ b/dvc/exceptions.py
@@ -31,6 +31,18 @@ def __init__(self, output, stages):
         super(OutputDuplicationError, self).__init__(msg.format(output, s))
 
 
+class WorkingDirectoryAsOutputError(DvcException):
+    def __init__(self, cwd, fname):
+        assert isinstance(cwd, str)
+        assert isinstance(fname, str)
+        msg = (
+            "Current working directory '{cwd}' is specified as an output in"
+            " '{fname}'. Use another CWD to prevent any data removal."
+            .format(cwd=cwd, fname=fname)
+        )
+        super(WorkingDirectoryAsOutputError, self).__init__(msg)
+
+
 class CircularDependencyError(DvcException):
     def __init__(self, dependency):
         assert isinstance(dependency, str)
diff --git a/dvc/project.py b/dvc/project.py
--- a/dvc/project.py
+++ b/dvc/project.py
@@ -165,10 +165,20 @@ def install(self):
     def to_dvc_path(self, path):
         return os.path.relpath(path, self.root_dir)
 
-    def _check_output_duplication(self, outs):
+    def _check_cwd_specified_as_output(self, cwd, stages):
+        from dvc.exceptions import WorkingDirectoryAsOutputError
+
+        cwd_path = os.path.abspath(os.path.normpath(cwd))
+
+        for stage in stages:
+            for output in stage.outs:
+                if os.path.isdir(output.path) and output.path == cwd_path:
+                    raise WorkingDirectoryAsOutputError(cwd, stage.relpath)
+
+    def _check_output_duplication(self, outs, stages):
         from dvc.exceptions import OutputDuplicationError
 
-        for stage in self.stages():
+        for stage in stages:
             for o in stage.outs:
                 for out in outs:
                     if o.path == out.path and o.stage.path != out.stage.path:
@@ -203,6 +213,7 @@ def add(self, fname, recursive=False):
         else:
             fnames = [fname]
 
+        all_stages = self.stages()
         stages = []
         self._files_to_git_add = []
         with self.state:
@@ -211,7 +222,7 @@ def add(self, fname, recursive=False):
                                     outs=[f],
                                     add=True)
 
-                self._check_output_duplication(stage.outs)
+                self._check_output_duplication(stage.outs, all_stages)
 
                 stage.save()
                 stage.dump()
@@ -332,7 +343,10 @@ def run(self,
                             deps=deps,
                             overwrite=overwrite)
 
-        self._check_output_duplication(stage.outs)
+        all_stages = self.stages()
+
+        self._check_cwd_specified_as_output(cwd, all_stages)
+        self._check_output_duplication(stage.outs, all_stages)
         self._check_circular_dependency(stage.deps, stage.outs)
 
         self._files_to_git_add = []
@@ -352,7 +366,8 @@ def imp(self, url, out):
                             deps=[url],
                             outs=[out])
 
-        self._check_output_duplication(stage.outs)
+        self._check_output_duplication(stage.outs, self.stages())
+        self._check_circular_dependency(stage.deps, stage.outs)
 
         self._files_to_git_add = []
         with self.state:
@@ -1116,6 +1131,14 @@ def pipelines(self):
         return pipelines
 
     def stages(self):
+        """
+        Walks down the root directory looking for Dvcfiles,
+        skipping the directories that are related with
+        any SCM (e.g. `.git`) or DVC itself (`.dvc`).
+
+        NOTE: For large projects, this could be an expensive
+              operation.  Consider using some memorization.
+        """
         stages = []
         outs = []
         for root, dirs, files in os.walk(self.root_dir):


Test patch:
diff --git a/tests/test_run.py b/tests/test_run.py
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -6,7 +6,9 @@
 from dvc.stage import Stage, StageFileBadNameError, MissingDep
 from dvc.stage import StageBadCwdError
 from dvc.command.run import CmdRun
-from dvc.exceptions import OutputDuplicationError, CircularDependencyError
+from dvc.exceptions import (OutputDuplicationError,
+                            CircularDependencyError,
+                            WorkingDirectoryAsOutputError)
 
 from tests.basic_env import TestDvc
 
@@ -117,6 +119,19 @@ def test_non_normalized_paths(self):
                          fname='circular-dependency.dvc')
 
 
+class TestRunWorkingDirectoryAsOutput(TestDvc):
+    def test(self):
+        self.dvc.run(cmd='',
+                     deps=[],
+                     outs=[self.DATA_DIR])
+
+        with self.assertRaises(WorkingDirectoryAsOutputError):
+            self.dvc.run(cmd='',
+                         cwd=self.DATA_DIR,
+                         outs=[self.FOO],
+                         fname='inside-cwd.dvc')
+
+
 class TestRunBadCwd(TestDvc):
     def test(self):
         with self.assertRaises(StageBadCwdError):