Repository: iterative/dvc

Issue:
Newline in .gitignore



Discussion:
Scm driver how has newline detection implemented by @dmpetrov https://github.com/dataversioncontrol/dvc/blob/master/dvc/scm.py#L89 . Closing.
Can this issue be reopened? It seems that it's back in the last release (0.41.3)
Hi @nik123 !

Could you provide more details?
I have dvc 0.41.3 on Ubuntu 16.04 installed as a deb package. 

In my system I can easily reproduce the bug by executing the following commands in bash:
```
$ mkdir tmp
$ cd tmp
$ git init
$ dvc init
$ echo "Hello, world!" > data.txt
$ dvc add data.txt
```

The `.gitignore` file generated by `dvc add data.txt` command doesn't have a newline (i.e. `\n`) character at the end of `/data.txt` line. I can easily detect absence of newline character by executing:

```
$ git add .gitignore
$ git diff --cached .gitignore
```

The commands above give me the following output:

```
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..604fb1f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/data.txt
\ No newline at end of file
```


@nik123 Thanks! Reopening. 

Patch:
diff --git a/dvc/scm/git/__init__.py b/dvc/scm/git/__init__.py
--- a/dvc/scm/git/__init__.py
+++ b/dvc/scm/git/__init__.py
@@ -104,6 +104,16 @@ def _get_gitignore(self, path, ignore_file_dir=None):
 
         return entry, gitignore
 
+    @staticmethod
+    def _ignored(entry, gitignore_path):
+        if os.path.exists(gitignore_path):
+            with open(gitignore_path, "r") as fobj:
+                ignore_list = fobj.readlines()
+            return any(
+                filter(lambda x: x.strip() == entry.strip(), ignore_list)
+            )
+        return False
+
     def ignore(self, path, in_curr_dir=False):
         base_dir = (
             os.path.realpath(os.curdir)
@@ -112,31 +122,32 @@ def ignore(self, path, in_curr_dir=False):
         )
         entry, gitignore = self._get_gitignore(path, base_dir)
 
-        ignore_list = []
-        if os.path.exists(gitignore):
-            with open(gitignore, "r") as f:
-                ignore_list = f.readlines()
-            if any(filter(lambda x: x.strip() == entry.strip(), ignore_list)):
-                return
+        if self._ignored(entry, gitignore):
+            return
 
         msg = "Adding '{}' to '{}'.".format(
             os.path.relpath(path), os.path.relpath(gitignore)
         )
         logger.info(msg)
 
-        self._add_entry_to_gitignore(entry, gitignore, ignore_list)
+        self._add_entry_to_gitignore(entry, gitignore)
 
         self.track_file(os.path.relpath(gitignore))
 
         self.ignored_paths.append(path)
 
     @staticmethod
-    def _add_entry_to_gitignore(entry, gitignore, ignore_list):
-        content = entry
-        if ignore_list:
-            content = "\n" + content
-        with open(gitignore, "a", encoding="utf-8") as fobj:
-            fobj.write(content)
+    def _add_entry_to_gitignore(entry, gitignore):
+        with open(gitignore, "a+", encoding="utf-8") as fobj:
+            fobj.seek(0, os.SEEK_END)
+            if fobj.tell() == 0:
+                # Empty file
+                prefix = ""
+            else:
+                fobj.seek(fobj.tell() - 1, os.SEEK_SET)
+                last = fobj.read(1)
+                prefix = "" if last == "\n" else "\n"
+            fobj.write("{}{}\n".format(prefix, entry))
 
     def ignore_remove(self, path):
         entry, gitignore = self._get_gitignore(path)


Test patch:
diff --git a/tests/func/test_scm.py b/tests/func/test_scm.py
--- a/tests/func/test_scm.py
+++ b/tests/func/test_scm.py
@@ -2,6 +2,7 @@
 
 import os
 
+import pytest
 from git import Repo
 
 from dvc.utils.compat import str  # noqa: F401
@@ -61,80 +62,133 @@ def test_commit_in_submodule(self):
         self.assertTrue("foo" in self.git.git.ls_files())
 
 
-class TestIgnore(TestGit):
-    def _count_gitignore(self):
+class TestIgnore(object):
+    @staticmethod
+    def _count_gitignore_entries(string):
         lines = get_gitignore_content()
-        return len([i for i in lines if i == "/" + self.FOO])
+        return len([i for i in lines if i == string])
 
-    def test_ignore(self):
-        git = Git(self._root_dir)
-        foo = os.path.join(self._root_dir, self.FOO)
+    def test_ignore(self, git, repo_dir):
+        git = Git(repo_dir._root_dir)
+        foo = os.path.join(repo_dir._root_dir, repo_dir.FOO)
+
+        target = "/" + repo_dir.FOO
 
         git.ignore(foo)
-        self.assertTrue(os.path.isfile(Git.GITIGNORE))
-        self.assertEqual(self._count_gitignore(), 1)
+        assert os.path.isfile(Git.GITIGNORE)
+        assert self._count_gitignore_entries(target) == 1
 
         git.ignore(foo)
-        self.assertEqual(self._count_gitignore(), 1)
+        assert os.path.isfile(Git.GITIGNORE)
+        assert self._count_gitignore_entries(target) == 1
 
         git.ignore_remove(foo)
-        self.assertEqual(self._count_gitignore(), 0)
-
-    def test_get_gitignore(self):
-        data_dir = os.path.join(self._root_dir, "file1")
-        entry, gitignore = Git(self._root_dir)._get_gitignore(data_dir)
-        self.assertEqual(entry, "/file1")
-        self.assertEqual(
-            gitignore, os.path.join(self._root_dir, Git.GITIGNORE)
-        )
+        assert self._count_gitignore_entries(target) == 0
 
-        data_dir = os.path.join(self._root_dir, "dir")
-        entry, gitignore = Git(self._root_dir)._get_gitignore(data_dir)
-        self.assertEqual(entry, "/dir")
-        self.assertEqual(
-            gitignore, os.path.join(self._root_dir, Git.GITIGNORE)
-        )
+    def test_get_gitignore(self, git, repo_dir):
+        data_dir = os.path.join(repo_dir._root_dir, "file1")
+        entry, gitignore = Git(repo_dir._root_dir)._get_gitignore(data_dir)
+        assert entry == "/file1"
+        assert gitignore == os.path.join(repo_dir._root_dir, Git.GITIGNORE)
 
-    def test_get_gitignore_subdir(self):
-        data_dir = os.path.join(self._root_dir, os.path.join("dir1", "file1"))
-        entry, gitignore = Git(self._root_dir)._get_gitignore(data_dir)
-        self.assertEqual(entry, "/file1")
-        self.assertEqual(
-            gitignore, os.path.join(self._root_dir, "dir1", Git.GITIGNORE)
+        data_dir = os.path.join(repo_dir._root_dir, "dir")
+        entry, gitignore = Git(repo_dir._root_dir)._get_gitignore(data_dir)
+
+        assert entry == "/dir"
+        assert gitignore == os.path.join(repo_dir._root_dir, Git.GITIGNORE)
+
+    def test_get_gitignore_subdir(self, git, repo_dir):
+        data_dir = os.path.join(
+            repo_dir._root_dir, os.path.join("dir1", "file1")
+        )
+        entry, gitignore = Git(repo_dir._root_dir)._get_gitignore(data_dir)
+        assert entry == "/file1"
+        assert gitignore == os.path.join(
+            repo_dir._root_dir, "dir1", Git.GITIGNORE
         )
 
-        data_dir = os.path.join(self._root_dir, os.path.join("dir1", "dir2"))
-        entry, gitignore = Git(self._root_dir)._get_gitignore(data_dir)
-        self.assertEqual(entry, "/dir2")
-        self.assertEqual(
-            gitignore, os.path.join(self._root_dir, "dir1", Git.GITIGNORE)
+        data_dir = os.path.join(
+            repo_dir._root_dir, os.path.join("dir1", "dir2")
+        )
+        entry, gitignore = Git(repo_dir._root_dir)._get_gitignore(data_dir)
+        assert entry == "/dir2"
+        assert gitignore == os.path.join(
+            repo_dir._root_dir, "dir1", Git.GITIGNORE
         )
 
-    def test_get_gitignore_ignorefile_dir(self):
-        git = Git(self._root_dir)
+    def test_get_gitignore_ignorefile_dir(self, git, repo_dir):
+        git = Git(repo_dir._root_dir)
 
         file_double_dir = os.path.join("dir1", "dir2", "file1")
-        data_dir1 = os.path.join(self._root_dir, file_double_dir)
+        data_dir1 = os.path.join(repo_dir._root_dir, file_double_dir)
         dir1_real1 = os.path.realpath("dir1")
         entry, gitignore = git._get_gitignore(data_dir1, dir1_real1)
-        self.assertEqual(entry, "/dir2/file1")
-        gitignore1 = os.path.join(self._root_dir, "dir1", Git.GITIGNORE)
-        self.assertEqual(gitignore, gitignore1)
+        assert entry == "/dir2/file1"
+        gitignore1 = os.path.join(repo_dir._root_dir, "dir1", Git.GITIGNORE)
+        assert gitignore == gitignore1
 
         triple_dir = os.path.join("dir1", "dir2", "dir3")
-        data_dir2 = os.path.join(self._root_dir, triple_dir)
+        data_dir2 = os.path.join(repo_dir._root_dir, triple_dir)
         dir1_real2 = os.path.realpath("dir1")
         entry, gitignore = git._get_gitignore(data_dir2, dir1_real2)
-        self.assertEqual(entry, "/dir2/dir3")
-        gitignore2 = os.path.join(self._root_dir, "dir1", Git.GITIGNORE)
-        self.assertEqual(gitignore, gitignore2)
+        assert entry == "/dir2/dir3"
+        gitignore2 = os.path.join(repo_dir._root_dir, "dir1", Git.GITIGNORE)
+        assert gitignore == gitignore2
 
-    def test_get_gitignore_ignorefile_dir_upper_level(self):
-        git = Git(self._root_dir)
+    def test_get_gitignore_ignorefile_dir_upper_level(self, git, repo_dir):
+        git = Git(repo_dir._root_dir)
 
         file_double_dir = os.path.join("dir1", "dir2", "file1")
-        data_dir1 = os.path.join(self._root_dir, file_double_dir)
+        data_dir1 = os.path.join(repo_dir._root_dir, file_double_dir)
         ignore_file_dir = os.path.realpath(os.path.join("aa", "bb"))
 
-        with self.assertRaises(FileNotInTargetSubdirError):
+        with pytest.raises(FileNotInTargetSubdirError):
             git._get_gitignore(data_dir1, ignore_file_dir)
+
+    def test_gitignore_should_end_with_newline(self, git, repo_dir):
+        git = Git(repo_dir._root_dir)
+
+        foo = os.path.join(repo_dir._root_dir, repo_dir.FOO)
+        bar = os.path.join(repo_dir._root_dir, repo_dir.BAR)
+        gitignore = os.path.join(repo_dir._root_dir, Git.GITIGNORE)
+
+        git.ignore(foo)
+
+        with open(gitignore, "r") as fobj:
+            last = fobj.readlines()[-1]
+
+        assert last.endswith("\n")
+
+        git.ignore(bar)
+
+        with open(gitignore, "r") as fobj:
+            last = fobj.readlines()[-1]
+
+        assert last.endswith("\n")
+
+    def test_gitignore_should_append_newline_to_gitignore(self, git, repo_dir):
+        git = Git(repo_dir._root_dir)
+
+        foo_ignore_pattern = "/foo"
+        bar_ignore_pattern = "/bar"
+        bar_path = os.path.join(repo_dir._root_dir, repo_dir.BAR)
+        gitignore = os.path.join(repo_dir._root_dir, Git.GITIGNORE)
+
+        with open(gitignore, "w") as fobj:
+            fobj.write(foo_ignore_pattern)
+
+        with open(gitignore, "r") as fobj:
+            last = fobj.readlines()[-1]
+        assert not last.endswith("\n")
+
+        git.ignore(bar_path)
+
+        with open(gitignore, "r") as fobj:
+            lines = list(fobj.readlines())
+
+        assert len(lines) == 2
+        for l in lines:
+            assert l.endswith("\n")
+
+        assert lines[0].strip() == foo_ignore_pattern
+        assert lines[1].strip() == bar_ignore_pattern