Repository: iterative/dvc

Issue:
checkout: doesn't remove excess files in directories
```
#!/bin/bash

set -e
set -x

rm -rf myrepo
mkdir myrepo
cd myrepo
git init
dvc init
git commit -s -m init

mkdir dir
echo 1 > dir/1

dvc add dir

echo 2 > dir/2

dvc checkout

ls dir/
```

dir/2 should've been removed by checkout. Related to https://github.com/iterative/dvc/commit/fc7545b6cf9579916ef0f1a0e269c76e7d4add0e



Discussion:
@efiop , I'm not sure about this behavior.

`git checkout` does not discard your current working directory (only if the `--` option is used), and if there are conflicting files between trees, git doesn't allow it. You need to consciously delete the files.

Maybe we can offer a `dvc clean` or a `dvc chekout -- `, what do you think?
It makes sense if we only apply this to cached directories (eg. `0800fc577294c34e0b28ad2839435945.dir`).
But `git checkout` doesn't restore the directory even if you use `--` :slightly_smiling_face: This is because git operates on files, and we operate on data, which could be a file _or_ a directory. Effectively `git add dir` is equivalent to `dvc add dir -R`(will add all the files inside dir recursively), not `dvc add dir` which would treat the whole directory as a data. And thus `dvc checkout dir.dvc` should act the same as `dvc checkout file.dvc` and `git checkout file`. Plus current behavior(the one described in the issue) is a regression, since it breaks backward compatibility.

Resolving patch:
diff --git a/dvc/remote/local.py b/dvc/remote/local.py
--- a/dvc/remote/local.py
+++ b/dvc/remote/local.py
@@ -337,11 +337,30 @@ def checkout(self, path_info, checksum_info):
             if bar:
                 progress.update_target(dir_relpath, processed, dir_size)
 
+        self._discard_working_directory_changes(path, dir_info)
+
         self.state.update_link(path)
 
         if bar:
             progress.finish_target(dir_relpath)
 
+    def _discard_working_directory_changes(self, path, dir_info):
+        working_dir_files = set(
+            os.path.join(root, file)
+            for root, _, files in os.walk(path)
+            for file in files
+        )
+
+        cached_files = set(
+            os.path.join(path, file['relpath'])
+            for file in dir_info
+        )
+
+        delta = working_dir_files - cached_files
+
+        for file in delta:
+            remove(file)
+
     def _move(self, inp, outp):
         # moving in two stages to make last the move atomic in
         # case inp and outp are in different filesystems


Test patch:
diff --git a/tests/test_checkout.py b/tests/test_checkout.py
--- a/tests/test_checkout.py
+++ b/tests/test_checkout.py
@@ -71,7 +71,7 @@ def test(self):
 class TestCheckoutCorruptedCacheDir(TestDvc):
     def test(self):
         time.sleep(1)
-        
+
         # NOTE: using 'copy' so that cache and link don't have same inode
         ret = main(['config', 'cache.type', 'copy'])
         self.assertEqual(ret, 0)
@@ -159,6 +159,20 @@ def test(self):
         self.assertFalse(os.path.exists(fname))
 
 
+class TestCheckoutCleanWorkingDir(CheckoutBase):
+    def test(self):
+        stages = self.dvc.add(self.DATA_DIR)
+        stage = stages[0]
+
+        working_dir_change = os.path.join(self.DATA_DIR, 'not_cached.txt')
+        with open(working_dir_change, 'w') as f:
+            f.write('not_cached')
+
+        ret = main(['checkout', stage.relpath])
+        self.assertEqual(ret, 0)
+        self.assertFalse(os.path.exists(working_dir_change))
+
+
 class TestCheckoutSelectiveRemove(CheckoutBase):
     def test(self):
         # Use copy to test for changes in the inodes